samagotchi 0.34.0 → 0.35.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +45 -1
  3. data/README.md +2 -0
  4. data/bin/chi +3 -0
  5. data/docs/broadcast.md +129 -0
  6. data/docs/cli.md +8 -0
  7. data/docs/configuration.md +2 -0
  8. data/docs/guardrails.md +4 -2
  9. data/docs/hooks.md +24 -3
  10. data/docs/plugins.md +1 -1
  11. data/docs/sessions.md +6 -6
  12. data/lib/samagotchi/bridge/card_store.rb +1 -1
  13. data/lib/samagotchi/bridge/turn_notice.rb +1 -1
  14. data/lib/samagotchi/broadcast/recipients.rb +63 -0
  15. data/lib/samagotchi/broadcast/scope_card.rb +94 -0
  16. data/lib/samagotchi/broadcast/tags.rb +154 -0
  17. data/lib/samagotchi/broadcast_command.rb +228 -0
  18. data/lib/samagotchi/bundles/guardrails/guardrails/rules.yml +11 -0
  19. data/lib/samagotchi/bundles/guardrails/manifest.yml +2 -2
  20. data/lib/samagotchi/bundles/source-links/hooks/source_links.rb +32 -12
  21. data/lib/samagotchi/bundles/source-links/manifest.yml +4 -3
  22. data/lib/samagotchi/bundles/source-links/source_links.md +1 -1
  23. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  24. data/lib/samagotchi/bundles/system/self_map.md +4 -0
  25. data/lib/samagotchi/children_status.rb +2 -16
  26. data/lib/samagotchi/config.rb +6 -0
  27. data/lib/samagotchi/engine.rb +29 -5
  28. data/lib/samagotchi/git_head.rb +24 -0
  29. data/lib/samagotchi/guardrails/verdict.rb +4 -3
  30. data/lib/samagotchi/hooks/registry.rb +11 -5
  31. data/lib/samagotchi/log_subscriber.rb +1 -1
  32. data/lib/samagotchi/note_command.rb +13 -13
  33. data/lib/samagotchi/note_delivery.rb +42 -0
  34. data/lib/samagotchi/plugin/context.rb +4 -4
  35. data/lib/samagotchi/session_manager.rb +4 -3
  36. data/lib/samagotchi/sessions_command.rb +3 -2
  37. data/lib/samagotchi/system_prompt.rb +1 -0
  38. data/lib/samagotchi/terminal_ui/attached_loop.rb +25 -2
  39. data/lib/samagotchi/terminal_ui/status_row.rb +23 -2
  40. data/lib/samagotchi/terminal_ui.rb +14 -0
  41. data/lib/samagotchi/tools/send_note.rb +5 -11
  42. data/lib/samagotchi/version.rb +1 -1
  43. data/lib/samagotchi/web/app.rb +3 -0
  44. data/lib/samagotchi/web/capabilities.rb +19 -0
  45. data/lib/samagotchi/web/public/activity.js +26 -6
  46. data/lib/samagotchi/web/public/app.js +515 -288
  47. data/lib/samagotchi/web/public/card.js +12 -3
  48. data/lib/samagotchi/web/public/command_view.js +2 -3
  49. data/lib/samagotchi/web/public/families_view.js +171 -0
  50. data/lib/samagotchi/web/public/format.js +52 -0
  51. data/lib/samagotchi/web/public/index.html +94 -21
  52. data/lib/samagotchi/web/public/live_timing.js +154 -0
  53. data/lib/samagotchi/web/public/live_turn.js +44 -0
  54. data/lib/samagotchi/web/public/sessions_list.js +228 -32
  55. data/lib/samagotchi/web/public/strip.js +5 -2
  56. data/lib/samagotchi/web/public/turn_html.js +107 -0
  57. data/lib/samagotchi/web/public/turn_model.js +5 -4
  58. data/lib/samagotchi/web/public/turn_view.js +43 -129
  59. data/lib/samagotchi/web/session_summary.rb +4 -0
  60. data/lib/samagotchi/worker.rb +166 -307
  61. data/lib/samagotchi/worker_inbound.rb +251 -0
  62. data/lib/samagotchi/worker_wakes.rb +120 -0
  63. metadata +15 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c6f812c6128ae3ffaf91d9cc04716a77d351fb57793a7d9a4863e675f1f2337f
4
- data.tar.gz: 25e7600fe6dc335b7d28320795a03ea9e8527185f6a0dd918787777458402f39
3
+ metadata.gz: b598dac8dadadd4dd37861ed690d09d09926520c2295bc998def4d75c3716ea7
4
+ data.tar.gz: 9d30ebb820024929bbb2dc1267011af7eb5710b33a4ba9625489a74d111edfbd
5
5
  SHA512:
6
- metadata.gz: 322e9f5a5b553f7ed1f2955307b8d5bd8c901a355ac3ffcdff6b3fcb5013b28011c7c330d9496486338c77d2c0f58e2854393d4cb40ec33d00ed2516a38bd066
7
- data.tar.gz: f6048763f53d9b865858842acaf60587d5e57a365aaba759c3c85fcf69532227c4b201fe0f878da3e48191dd43856637dad29537203a3dfd7159756caac6f778
6
+ metadata.gz: 2f40ed32d227d1017bc7abcba8ef40f5e38f2f7dd7876aa5f78a9290e5443489b8109183655a151a1345fef1fa3f3f49e5deafbac38059cdba3bec70b0585d71
7
+ data.tar.gz: 55aca343a9dc45e78791863286768c356e815dc1b9783993f2c4477edb991883a3c88ae208dd642137cab4c6ead58fc1d49228f201fefcd6878dd421c719af70
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.35.0] - 2026-10-06
12
+
13
+ ### Added
14
+
15
+ - `chi broadcast -m TEXT` (or stdin) shares a note with every session it may concern, without picking them: your
16
+ own sessions a worker or a chi REPL runs, or that ended a turn in the last 8 hours (`broadcast.active_hours`),
17
+ delegate children and scratch sessions left out. One gets it when the note shares a tag with it: a ticket id
18
+ (`broadcast.ticket_pattern`) in its branch or your prompts there, a pull request attached to it, a link. The rest
19
+ are listed as skipped; `--all` reaches every one, `--dry-run` shows who would get it and why. The model mentions a
20
+ broadcast briefly when it affects its current work and doesn't act on it unless asked. For you, not for an agent:
21
+ refused inside a session, and the guardrails bundle's `chi-broadcast` rule asks. See
22
+ [docs/broadcast.md](docs/broadcast.md).
23
+ - `chi sessions list --format json` has `delegate`: true for a child the `delegate` tool started, false for a fork.
24
+ - A parent session shows its delegate children at a glance: a `⑂ 3` chip in its web info bar (how many run, wait,
25
+ are done or failed in the tooltip; the waiting ones in the attention colour; a click opens all sessions), and
26
+ `⑂ 2 running · 1 waiting` in the terminal's status row.
27
+ - A hook's `event[:notify]` and a plugin's `ctx.notify` take `fallback_for: :display`: the line repeats what the
28
+ answer's display (`event[:present]`) shows, so a UI that renders the display leaves it out. The web with markdown
29
+ on does; the REPL, the attached TUI, `chi -p` and the web with markdown off still show it. See
30
+ [docs/hooks.md](docs/hooks.md#what-a-hook-can-do-the-runtime).
31
+ - Web, all sessions: each card has an archive button (unarchive on an archived one), and Select picks many cards
32
+ (click, Shift-click a range, "Select all shown") for Archive N / Unarchive M. A session with a running turn is
33
+ skipped with the reason on its card; one toast at the end has Undo.
34
+
35
+ ### Changed
36
+
37
+ - source-links 0.4.0 (needs chi 0.35.0; run `chi update`): with markdown on, the web no longer shows the
38
+ `sources:` line under an answer that links every ref it names; it still shows when a ref is only in code or
39
+ markdown is off, and the terminals print it as before.
40
+ - `chi note --source broadcast` is refused: that source is `chi broadcast`'s.
41
+ - Web: a parent's delegates fold into its card, on the strip and in all sessions, instead of being cards of their
42
+ own. A `▸ 3 delegates · 1 live · 1 waiting` chip opens their list (in the card in all sessions, a popover on the
43
+ strip); it opens by itself while one of them waits on you, and the card then wears the warning colour. A family
44
+ takes one strip slot, a search finds a parent by its delegates, and select mode picks cards. A fork and a delegate
45
+ whose parent isn't listed stay cards of their own.
46
+ - guardrails 0.7.0: the `chi-broadcast` rule asks before an agent runs `chi broadcast`.
47
+
48
+ ### Fixed
49
+
50
+ - Web: a tool row shows how long the call took while you watch the turn, as it does after a reload.
51
+
52
+ Update with `chi update` (guardrails 0.7.0, source-links 0.4.0).
53
+
11
54
  ## [0.34.0] - 2026-10-06
12
55
 
13
56
  ### Added
@@ -1708,7 +1751,8 @@ and long-lived sessions.
1708
1751
  - A macOS desktop helper (`chi desktop install`): a "Send to chi" Service and a
1709
1752
  hotkey panel that send selected text or the clipboard to your sessions.
1710
1753
 
1711
- [Unreleased]: https://github.com/dm1try/samagotchi/compare/v0.34.0...HEAD
1754
+ [Unreleased]: https://github.com/dm1try/samagotchi/compare/v0.35.0...HEAD
1755
+ [0.35.0]: https://github.com/dm1try/samagotchi/compare/v0.34.0...v0.35.0
1712
1756
  [0.34.0]: https://github.com/dm1try/samagotchi/compare/v0.33.0...v0.34.0
1713
1757
  [0.33.0]: https://github.com/dm1try/samagotchi/compare/v0.32.0...v0.33.0
1714
1758
  [0.32.0]: https://github.com/dm1try/samagotchi/compare/v0.31.0...v0.32.0
data/README.md CHANGED
@@ -120,6 +120,7 @@ chi web --open # web UI: this project's sessions (--s
120
120
  chi web --web-host lan # the web UI on your phone too: scan the QR code it prints
121
121
  chi sessions list # this project's saved sessions (--scope=all: every one)
122
122
  pbpaste | chi note --source slack <id> # background context for a session (no turn)
123
+ chi broadcast -m "payments API is down" # the same for every session it concerns (a shared ticket, PR or link)
123
124
  chi context add <PR URL> <id> # attached context: chi keeps it fresh, the agent hears when it changes
124
125
  pbpaste | chi send -m "same bug?" <id> # a message to a session, the clipboard quoted above it
125
126
  chi send --new --wait -m "review feat/x" # a new session you can watch in the web; prints the answer
@@ -202,6 +203,7 @@ once, for the session, for the repo, or for the whole rule in the repo.
202
203
  - [Memory](docs/memory.md): scopes and model-specific overlays
203
204
  - [Sessions](docs/sessions.md): storage, retention, `chi sessions`
204
205
  - [Attached context](docs/context.md): `chi context`, sources, scopes, waking, the github-pr bundle, safety
206
+ - [Broadcast](docs/broadcast.md): `chi broadcast`, who a note reaches, tags, what a session gets
205
207
  - [chi as a sub-agent](docs/sub-agent.md): `chi send --wait --format json`, `chi answer`, instructions for a parent agent
206
208
  - [Desktop helper](docs/desktop.md): `chi desktop`, the macOS "Send to chi" Service and hotkey
207
209
  - [Guardrails](docs/guardrails.md): allow / ask / deny for tool calls, rules, approvals
data/bin/chi CHANGED
@@ -40,6 +40,8 @@ SUBCOMMANDS = {
40
40
  "sessions" => ["samagotchi/sessions_command", "SessionsCommand"],
41
41
  # a context note for sessions (-m, --source, --all)
42
42
  "note" => ["samagotchi/note_command", "NoteCommand"],
43
+ # a note for every session it may concern (-m, --all, --dry-run)
44
+ "broadcast" => ["samagotchi/broadcast_command", "BroadcastCommand"],
43
45
  # live external text attached to sessions (add, push, ls, show, rm, mute)
44
46
  "context" => ["samagotchi/context_command", "ContextCommand"],
45
47
  # a message into sessions as the user's (-m)
@@ -95,6 +97,7 @@ parser = OptionParser.new do |opts|
95
97
  chi web [--port PORT] [--open] [--scope=all] the web UI
96
98
  chi sessions <list|stop|archive|unarchive|delete|prune|clean>
97
99
  chi note [--source NAME] [-m TEXT] ID...|--all background context for sessions
100
+ chi broadcast [-m TEXT] [--all] [--dry-run] a note for every session it may concern
98
101
  chi context <add|push|ls|show|refresh|rm|mute|unmute> live external text attached to sessions
99
102
  chi send [-m TEXT] ID...|--new a message into sessions, as if typed there
100
103
  chi answer ID --question QID --option N answer a question a session waits on
data/docs/broadcast.md ADDED
@@ -0,0 +1,129 @@
1
+ # Broadcast
2
+
3
+ `chi broadcast` shares one piece of information with every session it may
4
+ concern, without picking them: "payments API returns 500 since 14:00 (PAY-123)",
5
+ "the PRD for checkout v2 changed: no guest checkout". Each session it reaches
6
+ gets it as a [context note](sessions.md#context-notes) from `broadcast`, and
7
+ that session's agent decides whether it affects its work. Sessions it doesn't
8
+ concern never see it.
9
+
10
+ ```sh
11
+ chi broadcast -m "payments API returns 500 since 14:00 (PAY-123)"
12
+ pbpaste | chi broadcast # the note from stdin
13
+ chi broadcast --dry-run -m "…" # who would get it and why; nothing is delivered
14
+ chi broadcast --all -m "deploy freeze until 18:00" # every recipient, tag or not
15
+ ```
16
+
17
+ To tell one session or a few you picked, use `chi note ID...` instead.
18
+
19
+ ## Who it reaches
20
+
21
+ The recipients are your own sessions, in every project:
22
+
23
+ - a worker or a chi REPL runs them now, or their last turn ended within
24
+ `broadcast.active_hours` (default 8). A session with no turn yet counts
25
+ by its last save;
26
+ - not a delegate child: its parent relays what concerns it (the
27
+ `coordinator`'s builders included). A fork is your session and counts;
28
+ - not a `chi scratch` session, not an archived one, and not a test run
29
+ (unless `chi broadcast` itself runs as one, `SAMAGOTCHI_ENV=test`).
30
+
31
+ A session open in a plain chi REPL (`--no-shared`) can't take notes: it is
32
+ listed as skipped, "open in a chi REPL".
33
+
34
+ ## Tags: which sessions it concerns
35
+
36
+ A recipient gets the note when the note and the session share a tag:
37
+
38
+ | Tag | In the note | About the session |
39
+ |---|---|---|
40
+ | ticket | an id like `PAY-123` (`broadcast.ticket_pattern`) | its branch (any case: `feat/pay-123-retry`), your prompts there |
41
+ | pr | `PR #42`, a GitHub pull request URL | an attached `pr-42` source (the github-pr bundle), a PR URL in your prompts there |
42
+ | link | a URL, by host and path | a URL in your prompts there or in an attached context source's hint |
43
+
44
+ A link is compared without its scheme, `www.`, query, fragment or trailing
45
+ slash; a bare host (`https://github.com/`) is no tag. A PR URL names its repo,
46
+ so `acme/shop#42` doesn't match `acme/other#42`; `PR #42` with no repo matches
47
+ pull request 42 of any repo. Words the default ticket pattern would take that
48
+ are no ticket (`UTF-8`, `SHA-256`, `ISO-8601`, `GPT-4`, …) are left out.
49
+
50
+ A session that shares no tag is skipped for now, listed as "no tag match (not
51
+ checked: no triage yet)": a small triage model that reads the note and each
52
+ session's scope card is the next step. Until then, `--all` reaches them all.
53
+
54
+ ## What it prints
55
+
56
+ One line per recipient, the ones that got it first, then a summary:
57
+
58
+ ```
59
+ broadcast "payments API returns 500 since 14:00 (PAY-123)"
60
+ 3f2a1c9e delivered ticket PAY-123 matches (branch)
61
+ 91ab02c4 delivered link notion.so/team/checkout-v2 matches (messages); waits for its next start
62
+ c0ffee12 skipped no tag match (not checked: no triage yet)
63
+ d00dad00 skipped open in a chi REPL
64
+ delivered 2 · skipped 2
65
+ ```
66
+
67
+ A session a worker runs adds the note within a few seconds (after a running
68
+ turn); one with no worker gets it at its next start ("waits for its next
69
+ start"). Nothing starts a turn anywhere.
70
+
71
+ `--dry-run` prints the note's tags, each recipient's verdict ("would get it" or
72
+ "skipped") and its scope card: what chi knows about it from local state, and
73
+ what a triage model will read.
74
+
75
+ ```
76
+ 3f2a1c9e would get it ticket PAY-123 matches (branch)
77
+ project: shop (folder ../shop-pay, branch feat/pay-123-retry)
78
+ title: shop-pay · now the retry spec
79
+ tags: ticket PAY-123 · pr acme/shop#42
80
+ started: Fix the payments retry …
81
+ recap: Fixing the payments retry. Specs next.
82
+ recent: now the retry spec
83
+ ```
84
+
85
+ Exit status: 0 when it ran (skipped sessions included), 1 when it was refused,
86
+ no session is active or a delivery failed, 2 for a usage error.
87
+
88
+ ## What a session gets
89
+
90
+ The note is your text, then one line chi adds:
91
+
92
+ ```
93
+ [CONTEXT NOTE from broadcast, 14:20]
94
+ payments API returns 500 since 14:00 (PAY-123)
95
+ (Shared by your user on 2026-10-06 14:20 with the sessions it may concern; it reached you because ticket PAY-123 matches your branch.)
96
+ [END NOTE]
97
+ ```
98
+
99
+ With `--all` the line says "with every active session". The date is there
100
+ because a session with no worker may read it days later. The system prompt
101
+ tells the model what a note from `broadcast` is: if it affects its current
102
+ work, it says so briefly in its next answer, otherwise it ignores it, and it
103
+ doesn't act on it unless you ask. Other notes keep their rule (background,
104
+ not mentioned on their own). `chi note --source broadcast` is refused, so a
105
+ plain note can't pass for a broadcast.
106
+
107
+ The note and its line together are capped at 16 KiB like any note; the text
108
+ may take up to 15872 bytes.
109
+
110
+ ## For you, not for an agent
111
+
112
+ `chi broadcast` refuses to run inside a chi session (where
113
+ `SAMAGOTCHI_PARENT_SESSION` is set): "chi broadcast is for your user, not an
114
+ agent". With the guardrails bundle, the `chi-broadcast` rule also asks before
115
+ any shell command that runs it, once at a time, and only you may allow it
116
+ (a parent agent's `chi answer` can't). See [Guardrails](guardrails.md).
117
+
118
+ ## Settings
119
+
120
+ | Setting | Default | What it does |
121
+ |---|---|---|
122
+ | `broadcast.active_hours` | `8` | A session with no worker or REPL counts when its last turn ended within this many hours. |
123
+ | `broadcast.ticket_pattern` | `\b[A-Z][A-Z0-9]+-\d+\b` | A Ruby regex for ticket ids; an invalid one warns and the default is used. |
124
+
125
+ ```yaml
126
+ broadcast:
127
+ active_hours: 12
128
+ ticket_pattern: '\b(?:PAY|OPS)-\d+\b'
129
+ ```
data/docs/cli.md CHANGED
@@ -790,11 +790,19 @@ changes:
790
790
 
791
791
  ```
792
792
  status> model=Qwen3.6-35B | ↳ 3f2a1c9e | ctx=12.3% (under20) | mem: notes, cli_usage | muted: gh-helper
793
+ status> model=Qwen3.6-35B | ⑂ 2 running · 1 waiting | ctx=8.1% (under20) | mem: skill_coordinator
793
794
  ```
794
795
 
795
796
  - `model=`: the model in use, `(default: …)` beside it when it isn't the config's default, and
796
797
  `model=<served> (served; asked <name>)` when the server said it served another model.
797
798
  - `↳ <id>`: the session that delegated this one.
799
+ - `⑂ …`: this session's delegate children (forks aren't counted): how many run and how many wait
800
+ for an answer; only the counts there are, nothing when none does (`/children` has the rest:
801
+ done, failed, whether a reply was reported). The terminal reads them from the children's files
802
+ (the delegates in the session's `delegates.json`): an attached one when it joins, when a turn
803
+ ends and when a delegate's report merges into a turn, the REPL whenever it draws the row. A
804
+ child that starts waiting on an approval (which doesn't wake the parent) shows at the next of
805
+ those.
798
806
  - `ctx=`: the kernel's context estimate and its bucket, updated during a turn (on hosts that
799
807
  report none, `api: openai`, at the turn's end).
800
808
  - `mem:`: the memories the session read, with its `--memory` list; `muted:` its `--mute` list.
@@ -882,6 +882,8 @@ described in their own sections.
882
882
  | `recap.timeout` | `30` | yes | Seconds a recap request may take. |
883
883
  | `recap.min_user_turns` | `2` | yes | Prompts a session needs before it gets a recap. |
884
884
  | `recap.sentences` | `2-4` | yes | Recap length, `N` or `N-M` (1–10). |
885
+ | `broadcast.active_hours` | `8` | | `chi broadcast` reaches a session a worker or a chi REPL runs, or whose last turn ended within this many hours. See [Broadcast](broadcast.md). |
886
+ | `broadcast.ticket_pattern` | `\b[A-Z][A-Z0-9]+-\d+\b` | | A Ruby regex for ticket ids; one in the note and in a session's branch (any case) or prompts delivers the broadcast there. See [Broadcast](broadcast.md). |
885
887
  | `session.shared` | `true` | | Plain `chi` runs its session in a worker and attaches; `--no-shared` per run. |
886
888
  | `session.idle_exit_minutes` | `30` | yes | An unused worker exits after this; `0` = never. |
887
889
  | `session.keep_empty` | `false` | | Keep sessions nothing happened in. |
data/docs/guardrails.md CHANGED
@@ -5,7 +5,7 @@ loops (native and chat hosts). The verdict is **allow**, **ask** or
5
5
  **deny**; the strictest vote wins, and a deny can't be undone by a later
6
6
  voter. Of two asks the first one names the rule, except a rule only the
7
7
  user may allow (`chi-config`, `chi-hooks`, `shell-touches-chi`,
8
- `chi-context-cmd`): it takes over, and only the scopes both asks offer
8
+ `chi-context-cmd`, `chi-broadcast`): it takes over, and only the scopes both asks offer
9
9
  are offered.
10
10
 
11
11
  Who votes, in order:
@@ -427,7 +427,9 @@ hooks, approvals, bundles or attached context sources, or a `.git/hooks`
427
427
  (`shell-touches-chi`, `touches: chi_dirs`), unless they only read
428
428
  (`skip_read_only`); `chi context add … --cmd` (`chi-context-cmd`, once at a
429
429
  time: a command chi will run every few minutes outside the guardrails; see
430
- [Attached context](context.md#safety)); and answering chi's
430
+ [Attached context](context.md#safety)); `chi broadcast` (`chi-broadcast`,
431
+ once at a time and only by the user: it is the user's command, which also
432
+ refuses to run inside a session; see [Broadcast](broadcast.md)); and answering chi's
431
433
  questions around `chi answer`: `chi --attach` or `chi -p` with stdin from a
432
434
  pipe, a here-string or a file (`chi-answer-piped`), and `curl`/`wget` to a
433
435
  session's `/answer` route (`chi-answer-http`), both once or for the session.
data/docs/hooks.md CHANGED
@@ -82,6 +82,8 @@ class Watchful
82
82
  when :after_generation
83
83
  # One line in the REPL, the attached TUI and the web ("<bundle>> text",
84
84
  # or "hook> text" for a config hook); level: :warn colours it.
85
+ # fallback_for: :display marks a line a UI that renders the answer's
86
+ # display may leave out (below).
85
87
  event[:notify].call("the model repeated itself", level: :warn)
86
88
  when :before_tool_call
87
89
  # A single-select question through the question flow (REPL, attached
@@ -114,6 +116,21 @@ end
114
116
  known-names)` for a bundle hook, `audit.rb (config)` for a config hook,
115
117
  `turn hook` for one registered at runtime.
116
118
 
119
+ `event[:notify]` also takes `fallback_for:`, what the line stands in for, so
120
+ that a UI that already shows it can leave the line out. The one value is
121
+ `:display`: the line repeats what the hook's
122
+ [`event[:present]`](#presenting-the-answer-display-only) display shows (its
123
+ links), for a UI that doesn't render that display. The hook never names a UI;
124
+ each decides by what it renders. The web with markdown on (`web.markdown` and
125
+ the commonmarker gem) leaves such a line out, live and after a reload; with
126
+ markdown off it shows, as it does in the REPL, the attached TUI and
127
+ `chi -p`, which print the answer as text. The log keeps the line with
128
+ `fallback_for=display`. Another value, or none, shows the line everywhere.
129
+ Mark a line only when the display really shows all of it (source-links marks
130
+ its note only when the answer links every URL the note names). It needs chi
131
+ 0.35.0: an older chi's `event[:notify]` raises `ArgumentError` on the keyword,
132
+ so a bundle that passes it sets `requires_chi: ">= 0.35.0"`.
133
+
117
134
  A steer is saved in the session as `{role: "user", kind: "steer", source:
118
135
  "<bundle>", content: "…"}`, its text raw. The model reads it as a user turn
119
136
  led by one line naming the sender: `[Steer from the <bundle> plugin,
@@ -504,8 +521,8 @@ sources: JIRA JIRA-123 → https://myjira.com/browse/JIRA-123, JIRA JIRA-10 →
504
521
  ```
505
522
 
506
523
  The note is **not part of the conversation**: it is an event shown to the
507
- user, never stored in the session file. A UI replays it while the session's
508
- worker lives (a page reload keeps it; a stopped worker loses it). Only the
524
+ user, never stored in the session file. A UI replays it with the session's
525
+ cards (a page reload keeps it, after the worker stopped too). Only the
509
526
  model's final answer is scanned (the last `role: "model"` message), and only
510
527
  the first 20 000 characters of it. A ref that is already a link is skipped —
511
528
  inside a bare URL (`https://x.com/JIRA-123`), in a markdown link's target, or
@@ -543,7 +560,11 @@ text stays as it was, and the links survive a reload and a stopped worker
543
560
  apply, and a ref in code (a `` `span` `` or a fenced block) or anywhere in a
544
561
  markdown link is left as it is; past the first 20 000 characters the answer
545
562
  is unchanged. The terminals see only the line; `note: false` drops it and
546
- keeps the web links.
563
+ keeps the web links. With markdown on, the web leaves the line out when the
564
+ answer links every URL it names, since it would only repeat them as plain
565
+ text ([`fallback_for: :display`](#what-a-hook-can-do-the-runtime)); it shows
566
+ when it names one the answer doesn't link (a ref only in code, two sources on
567
+ one ref) and whenever markdown is off. Bundle 0.4.0 needs chi 0.35.0 for that.
547
568
 
548
569
  The `prefix:` form compiles to `\b<prefix>-(\d+)\b` and the URL is
549
570
  `base_url` + the full ref text (`JIRA-123`). The `pattern:` form takes a
data/docs/plugins.md CHANGED
@@ -488,7 +488,7 @@ full label, `plugin.rb (bundle my-bundle)`.
488
488
  | `ctx.log` | `ctx.log.info(:event, key: value)`: debug-log records tagged `plugins`, with `bundle=<bundle>` |
489
489
  | `ctx.messages` | the conversation, as a frozen copy, without the system prompt. While a turn runs, a session worker's (attached, web) adds that turn so far: its prompt, the model's text and the lines merged into it (no tool calls or thinking); the REPL's is the conversation before that turn |
490
490
  | `ctx.messages_partial?` | whether `ctx.messages` leaves out a running turn (the REPL mid-turn), so a plugin can say what its answer is about |
491
- | `ctx.notify(text, level: :info)` | one line to the user, labelled by the bundle (`my-bundle> …`). Every UI shows it, during a turn (a tool, a hook) or between turns (a command) |
491
+ | `ctx.notify(text, level: :info, fallback_for: nil)` | one line to the user, labelled by the bundle (`my-bundle> …`). Every UI shows it, during a turn (a tool, a hook) or between turns (a command). `fallback_for: :display` marks a line that repeats the answer's display (`event[:present]`), which a UI that renders the display leaves out: see [Hooks](hooks.md#what-a-hook-can-do-the-runtime). The keyword needs chi 0.35.0 (it raises on an older chi) |
492
492
  | `ctx.card(title:, body: "", actions: [], level: :info, id: nil)` | a card in every UI, returning its id: see [Cards](#cards) |
493
493
  | `ctx.ask_user(question:, options:, header: nil, allow_freeform: false)` | a single-select question through the question flow: `{selected:, freeform:, selected_indices:}`, or nil (no one to ask, cancelled, bad options) |
494
494
  | `ctx.cancelled?` | whether the running turn was cancelled (a long tool should stop) |
data/docs/sessions.md CHANGED
@@ -60,14 +60,14 @@ chi sessions clean --dry-run --days 7 # test sessions older than 7 da
60
60
 
61
61
  **Projects:** a session belongs to the git project it was started in: the repository, whichever worktree or subfolder of it (the same project root memories use). `chi sessions list` (with `--live` and `--format` too) and `chi web` show the current folder's project; `--scope=all` shows every session, and so does a folder in no repo (`~`). `--cwd PATH` is a folder filter instead of the project. The project is stored with the session (`project_root` in its JSON, `project` in `--format json`), so a session keeps it after its worktree is deleted; sessions older than that are placed by their folder, and one whose folder is gone shows in `--scope=all` only. Retention, `--resume`/`--attach ID`, `chi send`/`chi note ID` and `chi note --all` (every live session) are not scoped. The agent's `list_sessions` lists its own project's sessions; `cwd: "/"` lists every one.
62
62
 
63
- `--live`, `--cwd` and `--format` make `list` a picker for scripts (`SessionManager.session_summaries`): `--live` keeps the sessions a worker runs now (the owner lock, not the saved status; a session open in a plain REPL is left out), `--cwd PATH` those in PATH or below, and test runs are left out unless the list is itself run as one (`SAMAGOTCHI_ENV=test`, `RACK_ENV=test` or `CI` set: then they show, marked `[test]`; `chi note --all` likewise). `--live` shows 10 unless `--limit` says otherwise; filters apply before the limit. `--format json` prints `[{id, short_id, desc, cwd, project, updated_at, live, busy, owner, recap, parent_id, archived, scratch, ctx_pct}]` (`owner`: `"worker"`, `"tui"` for a plain REPL, which takes no notes or messages, or null; `recap`: the first sentence of the session's recap, or null), `--format tsv` one `id<TAB>desc` line per session, where `desc` is `<folder> · <last prompt>` cut to 60 characters (the text form of `--live`/`--cwd` shows `<folder> · <recap>` when there is one; tsv and json keep `desc`).
63
+ `--live`, `--cwd` and `--format` make `list` a picker for scripts (`SessionManager.session_summaries`): `--live` keeps the sessions a worker runs now (the owner lock, not the saved status; a session open in a plain REPL is left out), `--cwd PATH` those in PATH or below, and test runs are left out unless the list is itself run as one (`SAMAGOTCHI_ENV=test`, `RACK_ENV=test` or `CI` set: then they show, marked `[test]`; `chi note --all` likewise). `--live` shows 10 unless `--limit` says otherwise; filters apply before the limit. `--format json` prints `[{id, short_id, desc, cwd, project, updated_at, live, busy, owner, recap, parent_id, delegate, archived, scratch, ctx_pct}]` (`owner`: `"worker"`, `"tui"` for a plain REPL, which takes no notes or messages, or null; `recap`: the first sentence of the session's recap, or null; `delegate`: true for a child the `delegate` tool started, false for a fork, which has a `parent_id` too), `--format tsv` one `id<TAB>desc` line per session, where `desc` is `<folder> · <last prompt>` cut to 60 characters (the text form of `--live`/`--cwd` shows `<folder> · <recap>` when there is one; tsv and json keep `desc`).
64
64
 
65
65
  **Ordering:**
66
66
 
67
67
  - `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.
68
- - 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); "All sessions" (or `/`) opens every session at `#/sessions`, with a search over preview, id and status (Esc or Back returns). The open session is in the URL (`#/s/<id>`), so a reload or a copied link opens it again; the chi logo top left goes back to the empty start for a new chat. The message box grows with its text; drag its top edge to keep it taller (double-click resets). The info bar copies `chi --attach <id>` for a terminal. 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. The start page's model picker (bottom left of the composer, the chosen name `▾`) chooses the model a new chat starts on; it opens a search list over the conversation (↓ or a click; downward when the start page leaves no room above): an empty search shows the last 5 picks (Recent) and then each host's model ids A–Z under the host, the default host first; typed words must each match the host or the id (a substring, else for 3+ characters the letters in order, so `deepseek4.1 fla` finds `openrouter · deepseek/deepseek-v4.1-flash`), ⏎ picks, Esc/Tab close, a pick puts the focus in the message box: `GET /api/models` lists the hosts' models as chi spells them (`{default, models: [{name, host, id, sampling?}], warning?}`; `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). The start page's first message goes as later ones do: `POST /api/sessions` with `idle: true` and `preview` (the message, which names the session until its turn is saved), then `POST /api/sessions/:id/turn`, so a failed first turn puts its prompt back in the composer too; a first line that is one of the new session's commands (`/model …`) goes to `POST /api/sessions/:id/command` instead, as later ones do. The browser remembers the last choice; a running session's model is in the info bar and changes only with `/model`. In the composer a line is a command when it is `!…` or starts with one of the session's command names (its `commands` list; `/stats`, `/exit` and the other terminal words get the page's own reply); an unknown `/word` (`/usr/bin/env is missing`) goes to the model as a prompt, as in a terminal; a one-word `/word` no command answers (`/modle`) is not sent: the page shows `Unknown command /modle. Did you mean /model? /help lists the commands.` (the "Did you mean" part only when a name is close). A `POST /turn` (or a session's first `prompt`) that is one of the worker's commands runs as that command too (`202 {status, command_id, session_id}`), so `chi send`, `chi -p` and a page whose command list lags all get the command. ↑/↓ in the composer walk the prompt history the TUI's ↑ walks ([Persistent Prompt History](cli.md#persistent-prompt-history)): ↑ with the caret on the first line, ↓ from the last line while browsing, ↓ past the newest brings the draft back; `GET /api/history` returns `{entries: [...]}`, oldest first (the page fetches it on focus and after each send). The server records a sent line in the routes once the worker took it (`POST /turn` accepted, a `!command` through `POST /command` or as a session's first line); `"history": false` in the body keeps it out (an image-only send).
68
+ - 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). The info bar copies `chi --attach <id>` for a terminal. 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. The start page's model picker (bottom left of the composer, the chosen name `▾`) chooses the model a new chat starts on; it opens a search list over the conversation (↓ or a click; downward when the start page leaves no room above): an empty search shows the last 5 picks (Recent) and then each host's model ids A–Z under the host, the default host first; typed words must each match the host or the id (a substring, else for 3+ characters the letters in order, so `deepseek4.1 fla` finds `openrouter · deepseek/deepseek-v4.1-flash`), ⏎ picks, Esc/Tab close, a pick puts the focus in the message box: `GET /api/models` lists the hosts' models as chi spells them (`{default, models: [{name, host, id, sampling?}], warning?}`; `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). The start page's first message goes as later ones do: `POST /api/sessions` with `idle: true` and `preview` (the message, which names the session until its turn is saved), then `POST /api/sessions/:id/turn`, so a failed first turn puts its prompt back in the composer too; a first line that is one of the new session's commands (`/model …`) goes to `POST /api/sessions/:id/command` instead, as later ones do. The browser remembers the last choice; a running session's model is in the info bar and changes only with `/model`. In the composer a line is a command when it is `!…` or starts with one of the session's command names (its `commands` list; `/stats`, `/exit` and the other terminal words get the page's own reply); an unknown `/word` (`/usr/bin/env is missing`) goes to the model as a prompt, as in a terminal; a one-word `/word` no command answers (`/modle`) is not sent: the page shows `Unknown command /modle. Did you mean /model? /help lists the commands.` (the "Did you mean" part only when a name is close). A `POST /turn` (or a session's first `prompt`) that is one of the worker's commands runs as that command too (`202 {status, command_id, session_id}`), so `chi send`, `chi -p` and a page whose command list lags all get the command. ↑/↓ in the composer walk the prompt history the TUI's ↑ walks ([Persistent Prompt History](cli.md#persistent-prompt-history)): ↑ with the caret on the first line, ↓ from the last line while browsing, ↓ past the newest brings the draft back; `GET /api/history` returns `{entries: [...]}`, oldest first (the page fetches it on focus and after each send). The server records a sent line in the routes once the worker took it (`POST /turn` accepted, a `!command` through `POST /command` or as a session's first line); `"history": false` in the body keeps it out (an image-only send).
69
69
  - 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).
70
- - The web frontend is a zero-build ES-module stack in `lib/samagotchi/web/public/`: `data.js` (retrieval, typed SSE `openStream` for a session and `openEvents` for the session list), `sessions_list.js` (the list as a pure reducer over `GET /api/events`: a snapshot replaces it, an upsert keeps a known card in place, a removal drops it), `notify.js` (which of those events needs the user, for the notifications), `app.js` (presentation/state; a session with no live stream gets one when its `session` event says `bridge_up`, after one re-read: `event_seq` starts over in each worker, so a dropped stream is never resumed with its old cursor), `format.js` (pure formatters such as `previewOf`), `prompt_history.js` (the composer's ↑/↓ over the shared prompt history). Unit-tested via `npm test` (`node --test spec/web/public/*.test.js`).
70
+ - 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), `notify.js` (which of those events needs the user, for the notifications), `app.js` (presentation/state; a session with no live stream gets one when its `session` event says `bridge_up`, after one re-read: `event_seq` starts over in each worker, so a dropped stream is never resumed with its old cursor), `format.js` (pure formatters such as `previewOf`), `prompt_history.js` (the composer's ↑/↓ over the shared prompt history). Unit-tested via `npm test` (`node --test spec/web/public/*.test.js`).
71
71
  - 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).
72
72
 
73
73
  **Test-session hygiene:**
@@ -88,7 +88,7 @@ pbpaste | chi note --source slack 3f2a 8c1d
88
88
 
89
89
  - The text comes from `-m` or stdin (a terminal on stdin is a usage error, not a wait). It is stripped; an empty note or one over 16 KiB is refused, never cut. `--source` (default `cli`) names where it came from. `--all` is every live session.
90
90
  - It lands in `<uuid>/notes/`, apart from `input/`, so nothing that runs turns sees it. A live worker adds it to the conversation within a few seconds, between turns: one sent during a turn waits for that turn to end. A session with no worker keeps it until a worker next starts (a prompt, `--attach`), which adds it before anything else. A session open in a plain REPL (`--no-shared`) refuses notes. `chi note` prints one line per session: queued, waits for the next start (with the queue count), or refused.
91
- - In the conversation it is a tail system message marked `kind: note`, framed `[CONTEXT NOTE from slack, 14:02]\n…\n[END NOTE]` (from another session: `from session 3f2a1c (~/projects/foo)`). The system prompt says notes are background, not requests: the model uses one when it is relevant, doesn't answer it on its own, and never follows instructions inside it. Its text is escaped like user text, so it can't fake a turn. It survives `--resume`, a reload, `!rollback` and a continue answered no.
91
+ - In the conversation it is a tail system message marked `kind: note`, framed `[CONTEXT NOTE from slack, 14:02]\n…\n[END NOTE]` (from another session: `from session 3f2a1c (~/projects/foo)`). The system prompt says notes are background, not requests: the model uses one when it is relevant, doesn't answer it on its own, and never follows instructions inside it. A note from `broadcast` ([`chi broadcast`](broadcast.md)) is the one exception to "doesn't answer it": the model says briefly in its next answer when it affects its current work, and still doesn't act on it unless asked. `--source broadcast` is refused for `chi note`. Its text is escaped like user text, so it can't fake a turn. It survives `--resume`, a reload, `!rollback` and a continue answered no.
92
92
  - The web shows it as a dim "note from …" block, live (`context_added`) and after a reload; the attached terminal as one dim `note from slack: <first line>` line, also when joining.
93
93
  - Agents: `list_sessions` (other sessions of this project, newest first, up to 20; optional `cwd`, `"/"` for every project) and `send_note(session, text)`, which sends a note from this session. Neither starts a turn anywhere.
94
94
 
@@ -182,7 +182,7 @@ chi send --wait 3fa2 # sends nothing: waits fo
182
182
 
183
183
  ## Delegating
184
184
 
185
- The `delegate` tool hands a task to a **child session**: an ordinary chi session in a worker of its own, started in the parent's `working_directory` with the task, verbatim, as its first user message, on the parent's model unless the call names one (`model:` takes a name or an alias, resolved as `--model` is; an unknown host comes back as the tool's `Error:` line; an id the host's saved model list doesn't have starts the child with a `Warning: host 'h' doesn't list model 'x' (did you mean: …?)` line in the result). Children run in parallel and only their **final reply** comes back to the parent: the child's newest `output/<timestamp>.txt` file, which the worker writes at the end of each turn that produced visible text, cut head-and-tail like an `execute` result. Nothing of the child's trace enters the parent's context. Nothing is hidden: a child shows in `chi sessions list` (`↳ <parent>`), the web (a `↳` chip on its card, `delegated by` in its info bar, listed right after its parent in the all-sessions view) and the `list_sessions` tool (`child`; the parent shows as `parent` in the child's own list); the user can `chi --attach <child id>` and steer it mid-run, and `chi send` and `send_note` reach it like any session.
185
+ The `delegate` tool hands a task to a **child session**: an ordinary chi session in a worker of its own, started in the parent's `working_directory` with the task, verbatim, as its first user message, on the parent's model unless the call names one (`model:` takes a name or an alias, resolved as `--model` is; an unknown host comes back as the tool's `Error:` line; an id the host's saved model list doesn't have starts the child with a `Warning: host 'h' doesn't list model 'x' (did you mean: …?)` line in the result). Children run in parallel and only their **final reply** comes back to the parent: the child's newest `output/<timestamp>.txt` file, which the worker writes at the end of each turn that produced visible text, cut head-and-tail like an `execute` result. Nothing of the child's trace enters the parent's context. Nothing is hidden: a child shows in `chi sessions list` (`↳ <parent>`), the web (folded into its parent's card on the strip and in all sessions, whose `▸ N delegates` chip lists the delegates at any depth, see Web UI above; a card of its own with a `↳` chip when its parent isn't listed; `delegated by` in its info bar; the parent's info bar carries a `⑂ N` chip counting its delegates, with how many run, wait, are done, failed, stopped or idle in the tooltip and the waiting ones named in the attention colour; a click opens all sessions), the terminal's status row (a parent's `⑂ 2 running · 1 waiting`, see [Status Line](cli.md#status-line)) and the `list_sessions` tool (`child`; the parent shows as `parent` in the child's own list); the user can `chi --attach <child id>` and steer it mid-run, and `chi send` and `send_note` reach it like any session.
186
186
 
187
187
  - `delegate(task, model:, session:, cwd:, wait:, timeout:)`: starts a child and, by default, waits for its reply. `wait: false` is the usual way for work that takes a while, several children side by side, or to keep talking with the user: it returns at once, the parent ends its turn or works on, and the reply comes back by itself as a *delegate report* (below), in a turn of its own if the parent is idle. The tool descriptions tell the model so, and to wait (`wait: true`, `delegate_result`) only when its turn can't go on without the reply. `timeout`, default 600 s, leaves a slow child running; 0 checks once without waiting. With `session:` (a child's id or prefix) the task goes to that child as a follow-up instead, delivered like `chi send` (client id `delegate:<parent short id>`, shown as `delegate>` in the child's UIs); only this session's children take one.
188
188
  - `cwd:` starts the child in another folder of the parent's repository: a linked worktree (`git worktree add ../<repo>-<task> -b <branch>`, which the model runs with `execute` first, under guardrails; chi runs no git here) or a subfolder; a relative path is taken from the parent's folder. The folder must exist, have the parent's project root (the same common git dir, `MemoryPaths.project_root`) and be in a work tree: a git dir (`.git`, `.git/objects`, a bare layout's `.bare`) or a bare layout's container is refused (`git rev-parse --is-inside-work-tree`, read-only, decides). Outside git only a subfolder of the parent's folder is allowed. Otherwise the tool returns `Error: cwd must be a folder of this session's repository …` or `… is not in a work tree …`. A follow-up (`session:`) refuses `cwd:`: the child keeps its folder. The child's guardrails measure from its own worktree, and project memories, `list_sessions` and the web's project are the same as the parent's.
@@ -205,4 +205,4 @@ The `delegate` tool hands a task to a **child session**: an ordinary chi session
205
205
  - Every child starts with the shipped `delegated` system memory preloaded (`preloaded_memory_names: ["system/delegated"]`; `--mute delegated` would hide it): put everything in the final reply, don't delegate further, don't write memories or change config, stay in the folder. Its system prompt also says `Delegated by session <parent id>`. The parent's own `--mute` list does not carry over.
206
206
  - **A child asks before it changes anything outside its folder** (the work tree top of the folder it started in): a write or edit there, mutating git in another checkout, a command that isn't read-only run from outside it or naming the main checkout or a sibling worktree. It's a core guardrail, on in every mode and without the guardrails bundle, and answered once or for the child's session ([Guardrails](guardrails.md#a-delegate-childs-boundary)); the approval relays to the parent's user as any other.
207
207
  - Limits: a child cannot `delegate` (depth 1; the tool refuses), and one session may have at most `session.max_children` (default `4`, env `SAMAGOTCHI_SESSION_MAX_CHILDREN`) children running at a time, counted from the session files (a finished or idle-exited child does not count; one whose worker is still starting does, for 15 s). A refused call creates no session. Nothing stops children with the parent: `chi sessions stop ID` does.
208
- - The link is the session's `parent_id`, written before the worker starts, so a respawn keeps it (and `delegate: true`, which a fork's `parent_id` doesn't come with; a child saved before that field is known by its `system/delegated` memory); `GET /api/sessions` and `/api/sessions/:id` carry it, and `SessionManager.children_of` lists a parent's children.
208
+ - The link is the session's `parent_id`, written before the worker starts, so a respawn keeps it (and `delegate: true`, which a fork's `parent_id` doesn't come with; a child saved before that field is known by its `system/delegated` memory); `GET /api/sessions`, `/api/sessions/:id` and the web hub's `session` frames carry it and `delegate` (a fork is `false`; the web parent's children chip counts delegates only); `SessionManager.children_of` lists a parent's children.
@@ -201,7 +201,7 @@ module Samagotchi
201
201
  end
202
202
 
203
203
  def add_notice(event)
204
- notice = event.slice(:type, :hook, :text, :level)
204
+ notice = event.slice(:type, :hook, :text, :level, :fallback_for)
205
205
  if event[:between_turns] || !@running
206
206
  push(notice.merge(in_turn: false, turns: @turns_done))
207
207
  else
@@ -10,7 +10,7 @@ module Samagotchi
10
10
  # here, and draw it with the handler that drew it live, where it was.
11
11
  module TurnNotice
12
12
  FIELDS = {
13
- hook_notice: %i[hook text level],
13
+ hook_notice: %i[hook text level fallback_for],
14
14
  empty_answer_retry: %i[iteration attempt of stopped_by malformed],
15
15
  steer_cut: %i[iteration source],
16
16
  question_requested: %i[pending_question],
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+ require_relative "../session"
5
+
6
+ module Samagotchi
7
+ # Loaded on first use: session_manager requires terminal_ui, which
8
+ # requires KernelLoop and so the tools (a require cycle otherwise).
9
+ autoload :SessionManager, File.expand_path("../session_manager", __dir__)
10
+
11
+ module Broadcast
12
+ # A session a broadcast may concern.
13
+ # @!attribute project [String, nil] its git project's root
14
+ # @!attribute live [Boolean] a worker runs it now
15
+ # @!attribute owner [String, nil] "worker", "tui" (a chi REPL, which
16
+ # takes no notes) or nil
17
+ Recipient = Data.define(:id, :short_id, :project, :cwd, :desc, :live, :owner) do
18
+ def repl? = owner == "tui"
19
+ end
20
+
21
+ # Who a broadcast goes to: the user's own sessions, not delegate
22
+ # children (their parent relays what concerns them) and not scratch
23
+ # ones, that a worker or a chi REPL runs now or that ended a turn in
24
+ # the last +active_hours+. Forks count: they are the user's sessions.
25
+ # Archived sessions don't; test runs only when chi runs as one.
26
+ module Recipients
27
+ module_function
28
+
29
+ # @return [Array<Recipient>] newest first
30
+ def list(state_dir:, active_hours:, include_tests: Session.test_session_env?, now: Time.now)
31
+ cutoff = now - (active_hours.to_f * 3600)
32
+ SessionManager.session_summaries(include_tests: include_tests, state_dir: state_dir).filter_map do |row|
33
+ next if row[:delegate] || row[:scratch]
34
+ next unless row[:live] || row[:owner] == "tui" || active_since?(row, cutoff, state_dir)
35
+
36
+ Recipient.new(id: row[:id], short_id: row[:short_id], project: row[:project], cwd: row[:cwd],
37
+ desc: row[:desc], live: row[:live], owner: row[:owner])
38
+ end
39
+ end
40
+
41
+ # The user's last turn there ended after +cutoff+. updated_at moves on
42
+ # every save (a note absorbed, a resume), so it counts only for a
43
+ # session with no turn recorded; it is never older than the last
44
+ # turn's end, so an older one rules the session out unread.
45
+ def active_since?(row, cutoff, state_dir)
46
+ return false unless (time(row[:updated_at]) || Time.at(0)) >= cutoff
47
+
48
+ session = Session.summary_from_file(Session.session_file(row[:id], state_dir: state_dir))
49
+ last_turn = session&.last_turn
50
+ ended = last_turn.is_a?(Hash) ? time(last_turn["ended_at"] || last_turn[:ended_at]) : nil
51
+ (ended || time(row[:updated_at])) >= cutoff
52
+ end
53
+ private_class_method :active_since?
54
+
55
+ def time(value)
56
+ value && Time.iso8601(value.to_s)
57
+ rescue ArgumentError
58
+ nil
59
+ end
60
+ private_class_method :time
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pathname"
4
+ require_relative "../session"
5
+ require_relative "../context_note"
6
+ require_relative "../context_sources"
7
+ require_relative "../git_head"
8
+ require_relative "../recap_store"
9
+ require_relative "tags"
10
+
11
+ module Samagotchi
12
+ module Broadcast
13
+ # What a broadcast knows about one recipient, from local state only:
14
+ # where it works, what it was started for, its recap, its last prompt
15
+ # and its tags. Built fresh for each broadcast, never stored; P2's
16
+ # triage model reads #to_s.
17
+ # @!attribute folder [String, nil] the session's folder relative to its
18
+ # project ("../app-fix" for a worktree), nil when it is the project
19
+ # root; outside a project the whole path, ~ for home
20
+ # @!attribute tags [Array<Tag>]
21
+ ScopeCard = Data.define(:id, :project, :folder, :branch, :title, :tags, :started, :recap, :recent) do
22
+ def to_s
23
+ where = [("folder #{folder}" if folder), ("branch #{branch}" if branch)].compact.join(", ")
24
+ lines = ["project: #{project || "none"}#{" (#{where})" unless where.empty?}"]
25
+ lines << "title: #{title}" unless title.to_s.empty?
26
+ lines << "tags: #{tags.map(&:label).join(" · ")}" unless tags.empty?
27
+ lines << "started: #{started}" unless started.to_s.empty?
28
+ lines << "recap: #{recap}" unless recap.to_s.empty?
29
+ lines << "recent: #{recent}" unless recent.to_s.empty?
30
+ lines.join("\n")
31
+ end
32
+ end
33
+
34
+ # Builds a ScopeCard for a Recipient.
35
+ module ScopeCards
36
+ STARTED_CHARS = 300
37
+ RECAP_CHARS = 400
38
+ RECENT_CHARS = 200
39
+
40
+ module_function
41
+
42
+ # @param recipient [Recipient]
43
+ # @param ticket [Regexp] the ticket pattern (Tags.ticket_regexp)
44
+ # @return [ScopeCard]
45
+ def build(recipient, state_dir:, ticket:)
46
+ session = Session.load(recipient.id, state_dir: state_dir)
47
+ prompts = user_texts(session.messages)
48
+ branch = GitHead.branch(session.working_directory)
49
+ sources = ContextSources.attached(session.id, project_root: session.project_root, state_dir: state_dir)
50
+ .map { |attached| [attached.name, attached.source.hint] }
51
+ ScopeCard.new(id: session.id, project: recipient.project && File.basename(recipient.project),
52
+ folder: folder(session.working_directory, recipient.project), branch: branch,
53
+ title: recipient.desc, tags: Tags.of_session(branch: branch, messages: prompts, sources: sources,
54
+ ticket: ticket),
55
+ started: cut(prompts.first, STARTED_CHARS),
56
+ recap: cut(RecapStore.read(Session.session_dir(session.id, state_dir: state_dir))&.dig(:text),
57
+ RECAP_CHARS),
58
+ recent: cut(session.last_prompt, RECENT_CHARS))
59
+ end
60
+
61
+ # The user's own messages, as text (an image part leaves its text).
62
+ def user_texts(messages)
63
+ messages.filter_map do |message|
64
+ next unless message[:role].to_s == "user"
65
+
66
+ content = message[:content]
67
+ text = if content.is_a?(Array)
68
+ content.filter_map { |part| part.is_a?(Hash) ? part[:text] || part["text"] : nil }.join("\n")
69
+ else
70
+ content.to_s
71
+ end
72
+ text unless text.strip.empty?
73
+ end
74
+ end
75
+ private_class_method :user_texts
76
+
77
+ def folder(cwd, project)
78
+ return ContextNote.home_relative(cwd) if project.nil?
79
+ return nil if cwd.to_s == project.to_s
80
+
81
+ Pathname.new(cwd.to_s).relative_path_from(Pathname.new(project.to_s)).to_s
82
+ rescue ArgumentError
83
+ cwd
84
+ end
85
+ private_class_method :folder
86
+
87
+ def cut(text, limit)
88
+ line = text.to_s.gsub(/\s+/, " ").strip
89
+ line.length > limit ? "#{line[0, limit - 1]}…" : line
90
+ end
91
+ private_class_method :cut
92
+ end
93
+ end
94
+ end