samagotchi 0.47.0 → 0.49.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 31cacb8f3d63a915a92684dcac667920837f96675f9c4256e3962cbfd95df5ef
4
- data.tar.gz: 29668be85fcc4178f470f15d8f2a1d1745cccb2a4e0391649ef85f8b5a5ad9bd
3
+ metadata.gz: ca248891309ffde628ce6c03c2bbd35fa946f7fa669978c30d1478a0f236a920
4
+ data.tar.gz: 50e85c20fa0882c5caeeaf7a2c141dd46a443c5f725d60120e194ada9152871c
5
5
  SHA512:
6
- metadata.gz: e4c46bb7eee9e046679b9d4ca15540b17f6b1e15f92756c3c55d7733fb8d313b3e5b12b5077027950dc7f1671d46a11d6aaac1aa72ce3d4668475e87aa0ebea3
7
- data.tar.gz: 86e41f7cd8fe707cc8ad6914a72ce183b824f86d6bbc4c953a356ff9d5709776e49c0b254734f2e675061de8d26c2c66bfcd538fe9d8f28f5f4d7d727b7decbc
6
+ metadata.gz: e6ce68e65e80c48433bdb55706aaa19790ab6a140f81550f63420c1f4574d1e423a2dd356a01fda3534234331eacfb9a03151e3fa83b602d027d039ad44c76ab
7
+ data.tar.gz: a476e538ff5913c82625a18c8e23b822bc402f7ed206724debe65c95863a1ab7ab2606823befae4c44a0e265b3a8a45eafcf08c6b2215a27dab4bd6c03ac98ec
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.49.0] - 2026-10-09
12
+
13
+ ### Added
14
+
15
+ - Native Gemma 4 on llama.cpp (with `--mmproj`) sees images: `@path`, `read` on an image and the web's pasted images
16
+ reach it, as they do Qwen 3.6. Before, chi refused with "profile gemma4 has no image template yet".
17
+ - Plugins: `ctx.frontend` says what runs the session: `:repl` (the REPL, `-p` without `--non-interactive`),
18
+ `:one_shot` (`-p --non-interactive`) or `:worker` (a session worker: the web, an attached TUI).
19
+
20
+ ### Changed
21
+
22
+ - github-pr 0.4.0: line links read the PR's files (`gh api`) only in a session worker (the web, an attached chi), not
23
+ in the REPL, `chi -p` or a scratch session, whose answers no web page shows while they run. `bundles: github-pr:
24
+ line_links: always` reads in the REPL and `-p` too; `false` still turns it off. The PR lookup and attach are
25
+ unchanged. See [Line links](docs/context.md#line-links).
26
+
27
+ ### Fixed
28
+
29
+ - Web: a command queued mid-turn (`/model X`) shows its bubble after the turn's answer once it runs, not among the
30
+ turn's rows above the answer.
31
+ - A background task whose process exited and whose pid another process reused since no longer reads as running in
32
+ `task_list`, `task_get` and `task_wait` until a `task_stop`: each read checks the process's start time.
33
+ - A continue that died after noting its moved delegates no longer notes them again when the new link's worker
34
+ finishes the move: `move.json` records who was told.
35
+ - An attached chi watching a `-p --non-interactive` run says a question there was dismissed because no one could
36
+ answer it (`(dismissed: no one to answer in a non-interactive run)`), as the run itself does, not
37
+ `(question cancelled)`.
38
+
39
+ Update with `chi update`: it updates github-pr (0.4.0: line links only where the web can show them). If you set
40
+ `line_links: true` to get links in the REPL, set `line_links: always` instead.
41
+
42
+ ## [0.48.0] - 2026-10-09
43
+
44
+ ### Changed
45
+
46
+ - Continuing a session moves its open delegates to the new link instead of refusing: running, waiting, live or with
47
+ an unreported reply, at any depth below. They stay unarchived, fold under the new link, and their reports (one
48
+ each, none lost or repeated) reach it; each gets a note naming its new parent. Only a delegate on an older chi's
49
+ worker (restart it) or open in a chi REPL still refuses the continue. See
50
+ [docs/sessions.md](docs/sessions.md#session-chains).
51
+ - Web: the continue toast counts the delegates that moved (`1 open delegate moved`); `POST /api/sessions` with
52
+ `continues` answers `moved: [ids]`.
53
+ - CLI: `chi send --new --continues` prints `started (continues 3fa2c1d0; 2 delegates moved)`.
54
+
55
+ Update with `chi update` (no bundle changed), then restart `chi web`. A delegate whose worker started on an older
56
+ chi refuses to move until it is restarted.
57
+
11
58
  ## [0.47.0] - 2026-10-09
12
59
 
13
60
  ### Added
@@ -2215,7 +2262,9 @@ and long-lived sessions.
2215
2262
  - A macOS desktop helper (`chi desktop install`): a "Send to chi" Service and a
2216
2263
  hotkey panel that send selected text or the clipboard to your sessions.
2217
2264
 
2218
- [Unreleased]: https://github.com/dm1try/samagotchi/compare/v0.47.0...HEAD
2265
+ [Unreleased]: https://github.com/dm1try/samagotchi/compare/v0.49.0...HEAD
2266
+ [0.49.0]: https://github.com/dm1try/samagotchi/compare/v0.48.0...v0.49.0
2267
+ [0.48.0]: https://github.com/dm1try/samagotchi/compare/v0.47.0...v0.48.0
2219
2268
  [0.47.0]: https://github.com/dm1try/samagotchi/compare/v0.46.1...v0.47.0
2220
2269
  [0.46.1]: https://github.com/dm1try/samagotchi/compare/v0.46.0...v0.46.1
2221
2270
  [0.46.0]: https://github.com/dm1try/samagotchi/compare/v0.45.0...v0.46.0
@@ -904,7 +904,7 @@ Whether a model can see images is found out before a turn with images is sent:
904
904
 
905
905
  - a native llama.cpp host: `/props` must report `modalities.vision` (the server
906
906
  runs with `--mmproj`) and a media marker, and the prompt profile must know the
907
- chat template's image wrapping (qwen36 does; gemma4 not yet);
907
+ chat template's image wrapping (qwen36 and gemma4 do);
908
908
  - mlx and oMLX hosts: no (not verified with recent chi versions);
909
909
  - an OpenAI-API host: a local llama.cpp's `/props`, else the host's model list
910
910
  (OpenRouter's `architecture.input_modalities`); when it doesn't say, the image
data/docs/context.md CHANGED
@@ -258,13 +258,20 @@ show the answer as it is.
258
258
  So the first answer of a very short turn may come without links, and
259
259
  after a push links may be a few lines off until the next read. Without
260
260
  `gh` or its login, nothing is linked.
261
+ - **Where it reads**: only in a session worker (the web, an attached chi),
262
+ whose answers the web shows while the session runs. The REPL and
263
+ `chi -p` read nothing (no `gh api` calls), so their answers have no links,
264
+ in the web too; a scratch session never reads. The PR itself is still
265
+ attached and its context still reaches the model everywhere.
261
266
 
262
- Turn it off:
267
+ `line_links:` takes `false` (off), `true` (the default: a worker only) or
268
+ `always` (the REPL and `chi -p` read too, so their answers are linked when
269
+ you open the session in the web later):
263
270
 
264
271
  ```yaml
265
272
  bundles:
266
273
  github-pr:
267
- line_links: false
274
+ line_links: false # or always
268
275
  ```
269
276
 
270
277
  ## Safety
@@ -19,7 +19,7 @@ Behavior:
19
19
 
20
20
  - `execute`, `task_create` and the `!cmd` shell bang run the command in a non-login shell: `/bin/sh -c` on Linux; on macOS, when `/bin/zsh` is there, `/bin/zsh --emulate sh +o ignore_braces +o sh_glob -o bash_rematch +o bsd_echo -c` (macOS's `/bin/sh` is bash 3.2, which can't parse an apostrophe in a heredoc inside `"$( )"`, the usual commit message shape). That mode reads no rc files and keeps POSIX word splitting, unmatched globs left as is, `{1..3}`, `[[ … =~ (…) ]]` with `BASH_REMATCH`, arrays, `$'…'`, `&>` and echo expanding `\t`. Bash-only builtins and variables (`shopt`, `BASH_VERSION`, `${!prefix*}`, `read -p`) are not there.
21
21
  - Task metadata and output are persisted under `tmp/tasks/`.
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` and `task_wait`'s polls check the group only (no `ps` per poll).
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
  - Task listing is workspace-scoped (current project only).
24
24
  - `task_get` returns metadata and `output_path`; use `read` for output contents.
25
25
  - `task_wait` accepts `timeout`, `tail_lines` (maximum 100), and `done_pattern` (a regular expression string).
data/docs/plugins.md CHANGED
@@ -505,6 +505,7 @@ full label, `plugin.rb (bundle my-bundle)`.
505
505
  | `ctx.repo_root` | the git checkout holding `cwd`, or nil |
506
506
  | `ctx.scratch?` | whether the session is a `chi scratch` one (deleted when it ends) |
507
507
  | `ctx.delegate?` | whether the session is a delegate child: a task another session handed over |
508
+ | `ctx.frontend` | what runs the session, read now: `:repl` (the REPL, `-p` without `--non-interactive`), `:one_shot` (`-p --non-interactive`: one turn, then exit) or `:worker` (a session worker: the web, an attached TUI). Only a worker's answers reach the web while it runs; a REPL's show there once the session is opened. Until the host sets it (while the plugins load, inside `Engine.new`) it reads `:one_shot`, so read it in hooks and init tasks, not in `register` |
508
509
  | `ctx.fork?` | whether the session is a fork (`ctx.sessions.fork`): it has a parent but isn't a delegate child, and started from a conversation, so its first prompt isn't its first user message |
509
510
  | `ctx.model` | the model the session runs on now: its resolved ref (`host:id`), right after `/model` too |
510
511
  | `ctx.model_key` | that model's memory overlay key (`<name>.<key>.md`, what `memory_write current_model_only` writes) |
@@ -1240,8 +1241,9 @@ nothing without `gh`, a repository or an open PR), and resolves PR URLs for
1240
1241
  `chi context add` and the web's "+ URL". Its script prints the PR as text
1241
1242
  and a summary of what changed in counts, authors and states only; it wakes
1242
1243
  the session for a review requesting changes, checks turning red, or the PR
1243
- merged or closed. In the web, a `path:line` in an answer links to that line
1244
- of the session's PR ([Line links](context.md#line-links)). `auto_attach:
1244
+ merged or closed. In the web, a `path:line` in a worker's answer links to
1245
+ that line of the session's PR ([Line links](context.md#line-links); `line_links:
1246
+ always` for the REPL and `chi -p` too). `auto_attach:
1245
1247
  offer` asks with a card first (`/pr-attach N`, `/pr-decline N`), `off`
1246
1248
  attaches nothing ([Attach, offer or off](context.md#attach-offer-or-off)).
1247
1249
  See [Attached context](context.md#github-prs-the-github-pr-bundle).
data/docs/sessions.md CHANGED
@@ -182,7 +182,7 @@ chi send --new --continues last:3fa2 -m "new day" # the next link of 3fa2's
182
182
  - Exit codes with `--wait`: 0 answered; 3 the turn waits for an answer from you (a question, a hook's question, a guardrail approval, or the step-limit question: see [chi as a sub-agent](sub-agent.md#the-step-limit)), and the session keeps waiting: stderr gets the whole question (`waiting for an answer (approval): …`, its text, the numbered options, whether free text is allowed) and how to answer it (`chi answer ID --question QID --option N`, or `chi --attach ID`, the web); 4 `--timeout S` passed with the turn still running (it goes on; wait again); 1 the turn ended without a reply (canceled, failed or empty), the worker failed or vanished, or the session was stopped; 130 on Ctrl-C, which leaves the turn running (`still running: chi --attach ID`). There is no default timeout.
183
183
  - `--format json` (with `--wait`) prints one JSON object on stdout however the wait ends, for a script or a parent agent (see [chi as a sub-agent](sub-agent.md)): `{"status":"answered","session_id":…,"text":…}`; `{"status":"question",…,"question":{"id","kind","header","text","options","multi_select","allow_freeform","approval"},"answer_with":"chi answer ID --question QID --option N"}`, where `kind` is `question` (the model's), `hook` or `approval` (with the tool, command, cwd, rule, reason and offered scopes); `{"status":"running",…,"detail":…}` (exit 4, and on Ctrl-C); and `failed`, `canceled`, `no_answer`, `error`, `worker_gone` or `stopped` with a `detail` line (exit 1); `failed` adds `error_kind` and `retryable`, `canceled` adds `cancel_reason` and `stopped_by`, when known. A failure before the wait (no such session, a busy one) is `error` too. A usage error (exit 2) prints no JSON.
184
184
  - `--wait ID` with no message (no `-m`, nothing piped) sends nothing: it waits for the session's next reply and prints and exits as above. A running session is fine (that is the point), so after exit 3, 4 or 130 an agent waits again this way while the user answers in the web or `chi --attach`; the question pending when it starts doesn't count again. An idle session with no worker waits until something wakes one. With `--new`, or two ids, it is a usage error (exit 2).
185
- - `--continues ID` (with `--new`) starts the next link of that session's chain instead (see *Session chains*): in its folder, on its model and LLM context, so `--dir`, `--model` and `--llm-context*` are refused beside it; `ID` may be a prefix or `last:ID`, the chain's latest link. Without a message the new link starts idle; `--wait` then has nothing to wait for (a usage error). It prints `<full id> started (continues 3fa2c1d0)`; a refusal (continued already, naming the next link; a delegate still open; a turn running) is `chi send: refused: …`, exit 1.
185
+ - `--continues ID` (with `--new`) starts the next link of that session's chain instead (see *Session chains*): in its folder, on its model and LLM context, so `--dir`, `--model` and `--llm-context*` are refused beside it; `ID` may be a prefix or `last:ID`, the chain's latest link. Without a message the new link starts idle; `--wait` then has nothing to wait for (a usage error). It prints `<full id> started (continues 3fa2c1d0)`, with `; 2 delegates moved` inside the parentheses when open delegates moved to it; a refusal (continued already, naming the next link; an open delegate that can't move; a turn running) is `chi send: refused: …`, exit 1.
186
186
  - The 16 KiB cap applies: `git diff | chi send --new …` on a big diff is refused; name the branch in the message instead and let the session read it.
187
187
  - A session nobody attaches to stalls at its first question or guardrail ask until someone answers it (`chi answer`, the web, `chi --attach`), and its worker stays up meanwhile: it doesn't idle-exit while a question waits (`chi sessions list` shows it as `waiting`; `chi sessions stop ID` frees it). Otherwise it idle-exits after `session.idle_exit_minutes` like any worker. These are ordinary sessions: delete them like any other.
188
188
 
@@ -195,10 +195,12 @@ A session can **continue** an earlier one: "the next day of the same routine" (a
195
195
  - **What it copies:** the previous link's folder, its model (the name it was typed as, so an alias holds) and its own LLM context values. Its preview is the previous link's (until its first message is saved), unless it starts with a message.
196
196
  - **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.
197
197
  - **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.
198
- - **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 delegates are archived with it, as any archive does; so a continue is **refused while a delegate is still open** (at any depth, checked again after the recap wait): running, waiting for an answer, with a live worker, or with a reply its parent wasn't given (`ContinueRefused`, naming them). Wait for them, stop or archive them, then continue.
198
+ - **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.
199
+ - **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** (`ContinueRefused`, 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.
200
+ - **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.
199
201
  - **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 names a host config.yml no longer has. 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.
200
- - **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 not `dir`, `model` or `llm_context` (400 `invalid_continues`: the previous link's are taken). 201 is the new session's summary, `continues` set; 404 `not_found`; 400 `ambiguous_id` for a prefix of several sessions, `invalid_model` for a model whose host is gone; 409 `continued` with `next_id` (the link that continues it already, which the page opens instead), `open_children` with `ids`, `folder_gone`, `busy`, `scratch` or `owned_by_tui`. Every session summary carries `continues`.
201
- - **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.` in the composer, not sent (the same line for every chain; a draft already there stays instead); the previous link is archived, a toast says so. 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 shows a toast that names each one (its preview and why the server says it is open: `running`, `waiting`, `live`, `unreported reply`), 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 whose title is the opener and nothing else (its first message once sent) 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.
202
+ - **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 not `dir`, `model` or `llm_context` (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); 404 `not_found`; 400 `ambiguous_id` for a prefix of several sessions, `invalid_model` for a model whose host is gone; 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`.
203
+ - **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.` in the composer, not sent (the same line for every chain; a draft already there stays instead); 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. 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 whose title is the opener and nothing else (its first message once sent) 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.
202
204
  - **CLI:** `chi send --new --continues (ID|PREFIX|last:ID) [-m TEXT]` (see *Starting a session*).
203
205
  - **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.
204
206
 
@@ -219,7 +221,7 @@ The `delegate` tool hands a task to a **child session**: an ordinary chi session
219
221
  - No relay where the parent can't host one (a `--no-shared` REPL, `--non-interactive`) or isn't waiting (`wait: false` until the next `delegate_result`): the approval waits on the child, with the web's bell, and the result's question block says to deny it or leave it for the user. The model's own questions and hooks' questions come back to the model as before. A grandchild's (a plugin's `ctx.sessions.fork` can make one) is relayed hop by hop: `delegate ab12 → cd34 asks:`.
220
222
  - **Delegate reports: a child's reply comes back by itself.** When a child ends a turn that was its parent's (its task, a follow-up, a continue the parent answered, a reminder: not a turn its user typed into it directly), it rings the parent: a small file in the parent's `children/` dir, written after the child's turn is saved. So does a question the parent's model decides (the child's own `ask_user_question`, the step-limit continue; not an approval or a hook's question, which the user answers on the child's card), the turn after such a question whoever starts it, and the child's worker crashing. The parent reads the child as `delegate_result` would and hands its model a **report**: the same text (`session:`, `status:`, the reply), under its own header (`[Delegate report, delivered by chi …; not your user's message. Tell your user what it found …]`), as an input message with `source: "delegate_report"`.
221
223
  - Parent mid-turn: the report joins the turn at its next iteration boundary (a "delegate report" bubble in the web, `delegate report> ab12cd34 answered: …` in the attached terminal).
222
- - Parent idle: it runs a turn for the report (a *wake turn*, `last_turn.origin: "delegate_report"`) within the worker's 5 s tick, or wakes a worker for it when the parent's has idle-exited. Several reports that wait together are one turn and one message. A stopped, archived or scratch parent, or one open in a `--no-shared` REPL, is not woken: the rings wait on disk and join the next turn it runs (the user's message first). A worker just started (`chi send` to a stopped parent) runs no wake turn in its first 2 s, so the message it was started for goes first.
224
+ - Parent idle: it runs a turn for the report (a *wake turn*, `last_turn.origin: "delegate_report"`) within the worker's 5 s tick, or wakes a worker for it when the parent's has idle-exited. Several reports that wait together are one turn and one message. A stopped, archived or scratch parent, or one open in a `--no-shared` REPL, is not woken: the rings wait on disk and join the next turn it runs (the user's message first). An archived parent that a later link continues wakes that link instead, which takes the rings its moved delegates left there (see *Session chains*). A worker just started (`chi send` to a stopped parent) runs no wake turn in its first 2 s, so the message it was started for goes first.
223
225
  - The parent's cursor per child (the newest reply it was given and the child as it was then) is in its `delegates.json`, shared with `delegate_result` and `wait: true`: a reply the model already had is never reported again, also after the parent's worker restarts. A ring is deleted only when the turn that took its report stays in the conversation (completed, canceled, or failed but kept: one that got to tool steps, or a reminder or continue turn, which keep what they read); a turn rolled back (a failed one that got nowhere, a canceled continue turn) leaves it for the next one (and the report isn't handed back to the composer). So the model reads each report once, unless a later `!rollback` erases a kept turn (what it read goes with it).
224
226
  - Limits: `session.max_wakes` (default `10`) wake turns in a row with no human input; past it the reports wait for the next message and the UIs show `N delegate reports waiting; they join your next message`. A wake turn that fails, or is stopped before the model call (a Stop, a `before_turn` hook's cancel), pauses wakes until the next human input; its reports' rings stay, so they join that input's turn, and a failure's note tells the model so. A continue offer waiting in the parent takes the reports into its continue turn instead.
225
227
  - `session.delegate_reports`: `wake` (default), `queue` (no wake turns: reports join the parent's next turn) or `off` (no rings; only `delegate_result` asks, as before). Env `SAMAGOTCHI_SESSION_DELEGATE_REPORTS`, `SAMAGOTCHI_SESSION_MAX_WAKES`.
@@ -474,7 +474,9 @@ module Samagotchi
474
474
  # than a version.
475
475
  # restart: POST /exit takes "restart": true (WorkerIdleExit#hold_for_restart).
476
476
  # task_stop: POST tasks/stop (#handle_task_stop), the web's stop-task button.
477
- FEATURES = %w[restart task_stop].freeze
477
+ # reparent: the worker reads Session::PARENT_FILE (its rings, each turn's
478
+ # start), so a continue may move it to the chain's new link (ChildMove).
479
+ FEATURES = %w[restart task_stop reparent].freeze
478
480
 
479
481
  # The routes, by method and what follows /session/:id/: a handler takes
480
482
  # the session id and the request body and returns [headers, status, body].
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: github-pr
3
- version: 0.3.3
3
+ version: 0.4.0
4
4
  scope: system
5
5
  description: Attaches the branch's open GitHub pull request to a session as context (and any PR URL with chi context add or the web's "+ URL"); the agent gets a note when reviews, comments, checks or its state change; in the web, a path:line in an answer links to that line of the PR; the auto_attach setting can offer the PR with a card first, or attach nothing
6
6
  trust_level: reviewed
@@ -11,7 +11,7 @@ needs:
11
11
  hint: brew install gh, then gh auth login
12
12
  plugin:
13
13
  file: plugin.rb
14
- sha256: sha256:f68b53bfb552e35c9b151ee42cc10c07923481fc3d5496f023422e388df6719a
14
+ sha256: sha256:2510f8e5bfc77b2a6343b47d56bf6be9da09fd8741f52928462945a55d83f0ce
15
15
  scripts:
16
16
  pr_context.rb: sha256:a883d2780e89c68c0d7b6d5e0fcb22c273f17bb167e79064e0e99db6bd2ee6f0
17
17
  context_providers:
@@ -23,8 +23,11 @@ require "tmpdir"
23
23
  # model's text stays as it was. The after_turn hook links from a per-PR
24
24
  # cache and never runs gh (the web holds the answer until the hooks are
25
25
  # done); before_turn, before_generation and the init task fill the cache
26
- # off the turn's thread. `bundles: github-pr: line_links: false` turns it
27
- # off.
26
+ # off the turn's thread (`gh api`), in a session worker only: the REPL and
27
+ # -p --non-interactive show no web page while they run (ctx.frontend; a chi
28
+ # without it counts as a worker), and a scratch session never fetches.
29
+ # `bundles: github-pr: line_links:` false turns it off, "always" fetches in
30
+ # the REPL and -p too (still not for a scratch session).
28
31
  #
29
32
  # `bundles: github-pr: auto_attach:` says what the init task does with the
30
33
  # branch's open PR: `attach` (the default), `offer` (a card with Attach and
@@ -37,6 +40,8 @@ class Plugin
37
40
  COMMAND_SECONDS = 20
38
41
  # before_generation looks for a newly attached PR at most this often.
39
42
  GENERATION_CHECK_SECONDS = 30
43
+ # line_links' values: off, a worker's only (the default), every frontend.
44
+ LINE_LINKS_MODES = { false => :off, true => :worker, "always" => :always }.freeze
40
45
  # auto_attach's values (YAML's bare true/false: attach/off).
41
46
  AUTO_ATTACH_MODES = { "attach" => :attach, "offer" => :offer, "off" => :off, true => :attach, false => :off }.freeze
42
47
  # /pr-attach and /pr-decline take a PR number (`42` or `#42`).
@@ -50,6 +55,7 @@ class Plugin
50
55
  @cache = PrCache.new
51
56
  @generation_checked_at = nil
52
57
  @warned_mode = false
58
+ @warned_line_links = false
53
59
  end
54
60
 
55
61
  def register(chi)
@@ -58,13 +64,13 @@ class Plugin
58
64
  # Inline (an init task has its own thread), whatever the attach
59
65
  # returned (attached already, a child, a scratch session), and after
60
66
  # it, so a PR it just attached is warm for the first answer.
61
- refresh(ctx, attached_prs(ctx)) if line_links?(ctx)
67
+ refresh(ctx, attached_prs(ctx)) if prefetch?(ctx)
62
68
  summary
63
69
  end
64
70
  chi.on(:before_turn) do |event, ctx|
65
71
  # The prompt too: messages is the history before this turn, and a
66
72
  # delegate child's task is its first prompt.
67
- kick(ctx, attached_prs(ctx) + PrRef.from_messages(event[:messages], prompt: event[:prompt])) if line_links?(ctx)
73
+ kick(ctx, attached_prs(ctx) + PrRef.from_messages(event[:messages], prompt: event[:prompt])) if prefetch?(ctx)
68
74
  log_first_prompt(event, ctx)
69
75
  end
70
76
  chi.command("/pr-attach", "Attach the PR this session was offered: /pr-attach 42", anytime: true) do |args, ctx|
@@ -150,7 +156,34 @@ class Plugin
150
156
 
151
157
  private
152
158
 
153
- def line_links?(ctx) = ctx.settings["line_links"] != false
159
+ # `bundles: github-pr: line_links:`: :off, :worker (true or unset) or
160
+ # :always; an unknown value (a quoted "false") is :worker, logged once.
161
+ def line_links_mode(ctx)
162
+ value = ctx.settings["line_links"]
163
+ return :worker if value.nil?
164
+
165
+ LINE_LINKS_MODES.fetch(value.is_a?(String) ? value.strip.downcase : value) do
166
+ ctx.log.warn(:pr_line_links_unknown, value: value.to_s[0, 40]) unless @warned_line_links
167
+ @warned_line_links = true
168
+ :worker
169
+ end
170
+ end
171
+
172
+ def line_links?(ctx) = line_links_mode(ctx) != :off
173
+
174
+ # Whether to fetch PR data for line links (gh api): not for a scratch
175
+ # session (gone at exit), and by default only in a session worker, the
176
+ # one frontend whose answers the web shows while it runs. A chi before
177
+ # ctx.frontend (0.49.0) counts as a worker.
178
+ def prefetch?(ctx)
179
+ return false if ctx.scratch?
180
+
181
+ case line_links_mode(ctx)
182
+ when :off then false
183
+ when :always then true
184
+ else (ctx.respond_to?(:frontend) ? ctx.frontend : :worker) == :worker
185
+ end
186
+ end
154
187
 
155
188
  def offer_card_id(number) = "github-pr-offer-#{number}"
156
189
 
@@ -275,7 +308,7 @@ class Plugin
275
308
  # A PR attached while the turn runs (a parent attaching it right after
276
309
  # `delegate wait: false`), checked at most every GENERATION_CHECK_SECONDS.
277
310
  def check_attached(ctx)
278
- return unless line_links?(ctx)
311
+ return unless prefetch?(ctx)
279
312
  return if @generation_checked_at && now - @generation_checked_at < GENERATION_CHECK_SECONDS
280
313
 
281
314
  @generation_checked_at = now
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: samagotchi-system
3
- version: 0.47.0
3
+ version: 0.49.0
4
4
  scope: system
5
5
  description: Default system memories — identity, self map, config modification protocol, memory guide and the delegated-session rules
6
6
  files:
@@ -0,0 +1,249 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "json"
5
+ require "time"
6
+ require_relative "atomic_file"
7
+ require_relative "session"
8
+ require_relative "session_inbox"
9
+ require_relative "worker_sidecar"
10
+ require_relative "log"
11
+ require_relative "tools/delegate_cursor"
12
+
13
+ module Samagotchi
14
+ # Loaded on first use: session_manager requires terminal_ui, which
15
+ # requires KernelLoop and so the tools (a require cycle otherwise).
16
+ autoload :SessionManager, File.expand_path("session_manager", __dir__)
17
+
18
+ # A continue moving the previous link's open delegates to the chain's new
19
+ # link (SessionManager.continue_session), instead of archiving them with
20
+ # it. A moved child gets a parent override (Session::PARENT_FILE), never a
21
+ # rewritten session file, so its live worker saving a stale copy changes
22
+ # nothing; its worker reads the override for its rings and at each turn's
23
+ # start (Bridge::FEATURES "reparent").
24
+ #
25
+ # #apply runs between the new link's save and its worker's spawn, while
26
+ # the link is #mark_starting (a ring from a moved child must not spawn a
27
+ # second worker from the child's process until the link's own holds it).
28
+ # Each step is idempotent,
29
+ # and an intent file (INTENT_FILE) in the new link's folder names the move
30
+ # until it is done: a worker that starts and finds one finishes the move
31
+ # (#adopt), and a failed continue moves the same children back.
32
+ module ChildMove
33
+ # <new link dir>/move.json: {from, ids, noted}, while a move is under way.
34
+ INTENT_FILE = "move.json"
35
+ # <new link dir>/starting: {pid, at}, from the move to the worker's spawn.
36
+ STARTING_FILE = "starting"
37
+ # A starting marker older than this, or whose process is gone, is left
38
+ # over from a continue that died: it holds nothing back.
39
+ STARTING_STALE = 60.0
40
+ # Who the note to a moved child is from (ContextNote's label).
41
+ NOTE_SOURCE = "session chain"
42
+ # What a live child's worker must name in its sidecar to be moved.
43
+ FEATURE = "reparent"
44
+
45
+ # What a continue does with the previous link's children: +move+ the
46
+ # direct children whose subtree has an open node (they go to the new
47
+ # link, their subtrees with them), +refuse+ the open ones it can't move
48
+ # (SessionManager::OpenChild, why: an older chi worker or a chi REPL).
49
+ Plan = Data.define(:move, :refuse)
50
+ # A move under way: from the session, the children moved, and those
51
+ # already sent their note (so a move finished again notes no one twice).
52
+ Intent = Data.define(:from, :ids, :noted) do
53
+ def initialize(from:, ids:, noted: []) = super
54
+ end
55
+
56
+ module_function
57
+
58
+ # @param open [Array<SessionManager::OpenChild>] the previous link's open
59
+ # nodes, at any depth (SessionManager.open_children)
60
+ # @return [Plan]
61
+ def plan(prev_id, open:, state_dir:)
62
+ open_ids = open.map(&:id)
63
+ move = []
64
+ refuse = []
65
+ SessionManager.children_of(prev_id, state_dir: state_dir).each do |row|
66
+ subtree = [row[:id], *SessionManager.descendant_ids(row[:id], state_dir)]
67
+ next if (subtree & open_ids).empty?
68
+
69
+ if row[:archived]
70
+ # An open delegate below an archived one: the archive's cascade
71
+ # would take it, the move doesn't (only unarchived ones move).
72
+ refuse.concat(open.select { |c| subtree.include?(c.id) }.map do |c|
73
+ c.with(why: "under archived #{row[:id][0, 8]}; unarchive that one or archive this one")
74
+ end)
75
+ next
76
+ end
77
+
78
+ why = unmovable(row[:id], state_dir: state_dir)
79
+ if why
80
+ refuse << SessionManager::OpenChild.new(id: row[:id], short_id: row[:id][0, 8], why: why)
81
+ else
82
+ move << row[:id]
83
+ end
84
+ end
85
+ Plan.new(move: move, refuse: refuse)
86
+ end
87
+
88
+ # Why a child's live owner keeps it from moving: a chi REPL holds its
89
+ # own Session and never reads the override; a worker on an older chi
90
+ # neither. A worker with no sidecar yet is starting, on the newest chi
91
+ # installed (SessionManager.worker_command): it moves.
92
+ # @return [String, nil]
93
+ def unmovable(child_id, state_dir:)
94
+ owner = SessionManager.session_owner(child_id, state_dir: state_dir) or return nil
95
+ return "open in a chi REPL; close it there" if owner.tui?
96
+
97
+ sidecar = WorkerSidecar.live(Session.session_dir(child_id, state_dir: state_dir), unlink: false)
98
+ return nil if sidecar.nil? || sidecar.features.include?(FEATURE)
99
+
100
+ "older chi worker#{" #{sidecar.version}" if sidecar.version}; restart it"
101
+ end
102
+
103
+ # Move +ids+ (direct children of +from+) to +to+: the intent, the
104
+ # cursors +to+ lacks (delegates.json), the overrides, the rings waiting
105
+ # in +from+'s children/, a note to each child, then the intent goes.
106
+ # Safe to run again on the same move: the intent records each child
107
+ # noted, and a run finishing it (#adopt) notes only the rest. A crash
108
+ # between a note and its record notes that child twice (better than
109
+ # none).
110
+ # @param undo [Boolean] a failed continue moving them back: its note says so
111
+ def apply(ids, from:, to:, state_dir:, undo: false)
112
+ return if ids.empty?
113
+
114
+ intent = Intent.new(from: from, ids: ids)
115
+ left = read_intent(to, state_dir: state_dir)
116
+ intent = intent.with(noted: left.noted & ids) if left && left.from == from && left.ids.sort == ids.sort
117
+ write_intent(to, intent, state_dir: state_dir)
118
+ copy_cursors(ids, from: from, to: to, state_dir: state_dir)
119
+ ids.each { |id| Session.reparent(id, to: to, from: from, state_dir: state_dir) }
120
+ move_rings(ids, from: from, to: to, state_dir: state_dir)
121
+ (ids - intent.noted).each do |id|
122
+ note_child(id, to: to, undo: undo, state_dir: state_dir)
123
+ intent = intent.with(noted: intent.noted + [id])
124
+ write_intent(to, intent, state_dir: state_dir)
125
+ end
126
+ clear_intent(to, state_dir: state_dir)
127
+ Log.info(:worker, undo ? "delegates_moved_back" : "delegates_moved", from: from[0, 8], to: to[0, 8], count: ids.size)
128
+ end
129
+
130
+ # The rings in +from+'s children/ from children whose parent is +to+
131
+ # now (a failed continue's link, before it is deleted: a child that
132
+ # read the override just before the undo rang it).
133
+ def return_rings(from:, to:, state_dir:)
134
+ ids = SessionInbox.find_ring_files(Session.session_dir(from, state_dir: state_dir)).filter_map do |file|
135
+ id = SessionInbox.read_ring(file)&.dig(:child_id)
136
+ id if Session.valid_id?(id) && Session.parent_override(id, state_dir: state_dir) == to
137
+ end
138
+ move_rings(ids.uniq, from: from, to: to, state_dir: state_dir)
139
+ end
140
+
141
+ # A worker starting for +session_id+ finishes a move its continue left
142
+ # half done (the intent file is still there): only those children, never
143
+ # the predecessor's others.
144
+ # @return [Array<String>] the ids moved, [] with no intent
145
+ def adopt(session_id, state_dir:)
146
+ intent = read_intent(session_id, state_dir: state_dir) or return []
147
+ apply(intent.ids, from: intent.from, to: session_id, state_dir: state_dir)
148
+ intent.ids
149
+ rescue StandardError => e
150
+ Log.warn(:worker, "delegates_adopt_failed", sid: session_id[0, 8], error: e.class.name, msg: e.message)
151
+ []
152
+ end
153
+
154
+ # @return [Intent, nil]
155
+ def read_intent(session_id, state_dir:)
156
+ data = JSON.parse(File.read(File.join(Session.session_dir(session_id, state_dir: state_dir), INTENT_FILE)))
157
+ return nil unless data.is_a?(Hash) && Session.valid_id?(data["from"]) && data["ids"].is_a?(Array)
158
+
159
+ noted = data["noted"].is_a?(Array) ? data["noted"].select { |id| Session.valid_id?(id) } : []
160
+ Intent.new(from: data["from"], ids: data["ids"].select { |id| Session.valid_id?(id) }, noted: noted)
161
+ rescue JSON::ParserError, SystemCallError
162
+ nil
163
+ end
164
+
165
+ def write_intent(session_id, intent, state_dir:)
166
+ dir = Session.session_dir(session_id, state_dir: state_dir)
167
+ FileUtils.mkdir_p(dir)
168
+ AtomicFile.write(File.join(dir, INTENT_FILE), JSON.generate({ "from" => intent.from, "ids" => intent.ids, "noted" => intent.noted }))
169
+ end
170
+
171
+ def clear_intent(session_id, state_dir:)
172
+ FileUtils.rm_f(File.join(Session.session_dir(session_id, state_dir: state_dir), INTENT_FILE))
173
+ end
174
+
175
+ def copy_cursors(ids, from:, to:, state_dir:)
176
+ cursors = Tools::DelegateCursors.read(from, state_dir: state_dir).slice(*ids)
177
+ Tools::DelegateCursors.merge(to, cursors, state_dir: state_dir)
178
+ end
179
+
180
+ # A ring keeps its file name (its time orders the rings).
181
+ def move_rings(ids, from:, to:, state_dir:)
182
+ rings = SessionInbox.find_ring_files(Session.session_dir(from, state_dir: state_dir)).select do |file|
183
+ ids.include?(SessionInbox.read_ring(file)&.dig(:child_id))
184
+ end
185
+ return if rings.empty?
186
+
187
+ dir = File.join(Session.session_dir(to, state_dir: state_dir), SessionInbox::CHILDREN_DIR)
188
+ FileUtils.mkdir_p(dir)
189
+ rings.each do |file|
190
+ File.rename(file, File.join(dir, File.basename(file)))
191
+ rescue Errno::ENOENT
192
+ nil # taken meanwhile
193
+ end
194
+ end
195
+
196
+ # The child's system prompt names the parent it started with for its
197
+ # whole life (a prompt-cache miss each time it changed): a note tells a
198
+ # running one where its notes and reports go now.
199
+ def note_child(child_id, to:, state_dir:, undo: false)
200
+ text = if undo
201
+ "Your parent session is #{to[0, 8]} (#{to}) again: the continue that moved you to its next link " \
202
+ "failed. Send notes there, and your reports reach it."
203
+ else
204
+ "Your parent session is now #{to[0, 8]} (#{to}), the next link of its session chain: send notes " \
205
+ "there, and your reports reach it."
206
+ end
207
+ SessionInbox.write_note(child_id, text: text, source: NOTE_SOURCE, state_dir: state_dir)
208
+ end
209
+
210
+ # Hold off a worker for the continue's new link (#starting?) until its
211
+ # own worker holds it: written before the move, removed by that worker
212
+ # once it has the owner lock (Worker#start).
213
+ def mark_starting(session_id, state_dir:)
214
+ dir = Session.session_dir(session_id, state_dir: state_dir)
215
+ FileUtils.mkdir_p(dir)
216
+ AtomicFile.write(File.join(dir, STARTING_FILE), JSON.generate({ "pid" => Process.pid, "at" => Time.now.iso8601(3) }))
217
+ end
218
+
219
+ def clear_starting(session_id, state_dir:)
220
+ FileUtils.rm_f(File.join(Session.session_dir(session_id, state_dir: state_dir), STARTING_FILE))
221
+ end
222
+
223
+ # Whether a continue is moving delegates to +session_id+ right now: its
224
+ # marker is there, fresh, and the process that wrote it runs.
225
+ def starting?(session_id, state_dir:)
226
+ file = File.join(Session.session_dir(session_id, state_dir: state_dir), STARTING_FILE)
227
+ return false unless File.exist?(file)
228
+
229
+ data = JSON.parse(File.read(file))
230
+ return false if Time.now - Time.iso8601(data["at"].to_s) > STARTING_STALE
231
+
232
+ process_alive?(data["pid"].to_i)
233
+ rescue JSON::ParserError, ArgumentError, SystemCallError
234
+ false
235
+ end
236
+
237
+ # Signal 0 only asks whether the process exists.
238
+ def process_alive?(pid)
239
+ return false unless pid.positive?
240
+
241
+ Process.kill(0, pid)
242
+ true
243
+ rescue Errno::ESRCH
244
+ false
245
+ rescue Errno::EPERM
246
+ true
247
+ end
248
+ end
249
+ end