samagotchi 0.2.0 → 0.4.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 (131) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +198 -1
  3. data/README.md +56 -4
  4. data/bin/chi +118 -50
  5. data/docs/cli.md +184 -9
  6. data/docs/configuration.md +333 -47
  7. data/docs/desktop.md +45 -4
  8. data/docs/guardrails.md +11 -0
  9. data/docs/hooks.md +208 -5
  10. data/docs/plugins.md +68 -2
  11. data/docs/releasing.md +23 -13
  12. data/docs/sessions.md +45 -17
  13. data/lib/samagotchi/answer_display.rb +95 -0
  14. data/lib/samagotchi/archive_store.rb +90 -0
  15. data/lib/samagotchi/bootstrap/config_writer.rb +342 -0
  16. data/lib/samagotchi/bootstrap/probe.rb +262 -0
  17. data/lib/samagotchi/bootstrap_command.rb +347 -0
  18. data/lib/samagotchi/bridge/pending_card.rb +89 -0
  19. data/lib/samagotchi/bridge/turn_accumulator.rb +15 -3
  20. data/lib/samagotchi/bridge.rb +13 -1
  21. data/lib/samagotchi/bridge_client.rb +6 -2
  22. data/lib/samagotchi/bundles/check-in/manifest.yml +10 -0
  23. data/lib/samagotchi/bundles/check-in/plugin.rb +244 -0
  24. data/lib/samagotchi/bundles/source-links/hooks/source_links.rb +531 -0
  25. data/lib/samagotchi/bundles/source-links/manifest.yml +14 -0
  26. data/lib/samagotchi/bundles/source-links/source_links.md +5 -0
  27. data/lib/samagotchi/bundles/system/config_modification_protocol.md +10 -6
  28. data/lib/samagotchi/bundles/system/delegated.md +6 -7
  29. data/lib/samagotchi/bundles/system/manifest.yml +4 -4
  30. data/lib/samagotchi/bundles/system/self_map.md +8 -2
  31. data/lib/samagotchi/client.rb +81 -19
  32. data/lib/samagotchi/commands/registry.rb +8 -0
  33. data/lib/samagotchi/config.rb +252 -48
  34. data/lib/samagotchi/desktop/macos/App.swift +12 -8
  35. data/lib/samagotchi/desktop/macos/ChiRunner.swift +17 -9
  36. data/lib/samagotchi/desktop/macos/Images.swift +113 -0
  37. data/lib/samagotchi/desktop/macos/Info.plist.erb +6 -0
  38. data/lib/samagotchi/desktop/macos/Panel.swift +180 -25
  39. data/lib/samagotchi/desktop/macos.rb +59 -8
  40. data/lib/samagotchi/desktop_command.rb +6 -3
  41. data/lib/samagotchi/edit_preview.rb +82 -0
  42. data/lib/samagotchi/empty_answer_retry.rb +43 -0
  43. data/lib/samagotchi/engine.rb +434 -140
  44. data/lib/samagotchi/gem_update.rb +89 -0
  45. data/lib/samagotchi/guardrails/approval.rb +35 -4
  46. data/lib/samagotchi/guardrails/load_failures.rb +9 -3
  47. data/lib/samagotchi/guardrails/scratch_writes.rb +40 -0
  48. data/lib/samagotchi/guardrails.rb +1 -0
  49. data/lib/samagotchi/hooks/registry.rb +24 -5
  50. data/lib/samagotchi/host_registry.rb +9 -12
  51. data/lib/samagotchi/idle_client.rb +24 -15
  52. data/lib/samagotchi/idle_recap.rb +5 -1
  53. data/lib/samagotchi/idle_reminders.rb +2 -2
  54. data/lib/samagotchi/image_store.rb +10 -6
  55. data/lib/samagotchi/kernel_loop.rb +73 -94
  56. data/lib/samagotchi/live_versions.rb +59 -0
  57. data/lib/samagotchi/llm/api_key.rb +41 -0
  58. data/lib/samagotchi/llm/chat_loop.rb +132 -29
  59. data/lib/samagotchi/llm/errors.rb +41 -9
  60. data/lib/samagotchi/llm/http.rb +57 -17
  61. data/lib/samagotchi/llm/openai_chat.rb +17 -30
  62. data/lib/samagotchi/log_subscriber.rb +18 -3
  63. data/lib/samagotchi/memory_bundle/installer.rb +65 -63
  64. data/lib/samagotchi/memory_bundle/provenance.rb +51 -12
  65. data/lib/samagotchi/memory_bundle/shipped_update.rb +157 -0
  66. data/lib/samagotchi/memory_bundle/status.rb +4 -1
  67. data/lib/samagotchi/memory_bundle/system_bundle.rb +81 -53
  68. data/lib/samagotchi/model_profile.rb +24 -1
  69. data/lib/samagotchi/plugin/context.rb +22 -1
  70. data/lib/samagotchi/plugin/sessions.rb +3 -1
  71. data/lib/samagotchi/prompt.rb +4 -2
  72. data/lib/samagotchi/reminder_store.rb +1 -9
  73. data/lib/samagotchi/reply_wait.rb +126 -0
  74. data/lib/samagotchi/sampling_settings.rb +58 -0
  75. data/lib/samagotchi/self_report.rb +18 -3
  76. data/lib/samagotchi/send_command.rb +252 -11
  77. data/lib/samagotchi/session.rb +52 -11
  78. data/lib/samagotchi/session_archive_command.rb +107 -0
  79. data/lib/samagotchi/session_commands.rb +46 -7
  80. data/lib/samagotchi/session_manager.rb +115 -25
  81. data/lib/samagotchi/session_metrics.rb +222 -106
  82. data/lib/samagotchi/steer.rb +72 -0
  83. data/lib/samagotchi/terminal_ui/attached_loop.rb +57 -28
  84. data/lib/samagotchi/terminal_ui/event_renderer.rb +21 -11
  85. data/lib/samagotchi/terminal_ui/formatting.rb +40 -8
  86. data/lib/samagotchi/terminal_ui/input_support.rb +7 -19
  87. data/lib/samagotchi/terminal_ui/question_prompt.rb +35 -0
  88. data/lib/samagotchi/terminal_ui.rb +134 -247
  89. data/lib/samagotchi/text_diff.rb +181 -0
  90. data/lib/samagotchi/thinking.rb +115 -0
  91. data/lib/samagotchi/tool_activity.rb +3 -1
  92. data/lib/samagotchi/tool_runner.rb +34 -1
  93. data/lib/samagotchi/tools/ask_user_question.rb +41 -33
  94. data/lib/samagotchi/tools/builtins.rb +15 -4
  95. data/lib/samagotchi/tools/delegate_wait.rb +26 -69
  96. data/lib/samagotchi/tools/edit.rb +23 -9
  97. data/lib/samagotchi/tools/execute.rb +52 -14
  98. data/lib/samagotchi/tools/task_runtime.rb +19 -0
  99. data/lib/samagotchi/tools/task_wait.rb +27 -3
  100. data/lib/samagotchi/tools/write.rb +4 -0
  101. data/lib/samagotchi/turn_flow.rb +12 -2
  102. data/lib/samagotchi/turn_note.rb +60 -6
  103. data/lib/samagotchi/update_command.rb +308 -0
  104. data/lib/samagotchi/update_hint.rb +59 -0
  105. data/lib/samagotchi/version.rb +1 -1
  106. data/lib/samagotchi/vision_support.rb +7 -9
  107. data/lib/samagotchi/web/app.rb +91 -7
  108. data/lib/samagotchi/web/message_parts.rb +8 -3
  109. data/lib/samagotchi/web/public/activity.js +13 -1
  110. data/lib/samagotchi/web/public/annotate_presets.js +26 -0
  111. data/lib/samagotchi/web/public/annotations.js +13 -0
  112. data/lib/samagotchi/web/public/app.js +472 -111
  113. data/lib/samagotchi/web/public/card.js +5 -3
  114. data/lib/samagotchi/web/public/chat_view.js +13 -1
  115. data/lib/samagotchi/web/public/copy.js +20 -4
  116. data/lib/samagotchi/web/public/ctx.js +15 -0
  117. data/lib/samagotchi/web/public/data.js +23 -6
  118. data/lib/samagotchi/web/public/diff_view.js +58 -0
  119. data/lib/samagotchi/web/public/format.js +9 -0
  120. data/lib/samagotchi/web/public/index.html +60 -3
  121. data/lib/samagotchi/web/public/notify.js +175 -0
  122. data/lib/samagotchi/web/public/question_card.js +5 -2
  123. data/lib/samagotchi/web/public/sessions_list.js +7 -0
  124. data/lib/samagotchi/web/public/timing.js +39 -14
  125. data/lib/samagotchi/web/public/turn_events.js +75 -5
  126. data/lib/samagotchi/web/public/turn_view.js +49 -8
  127. data/lib/samagotchi/web/server.rb +8 -4
  128. data/lib/samagotchi/web/session_hub.rb +2 -1
  129. data/lib/samagotchi/web/session_summary.rb +24 -1
  130. data/lib/samagotchi/worker.rb +16 -4
  131. metadata +31 -1
data/docs/hooks.md CHANGED
@@ -50,7 +50,7 @@ The plugin class must respond to `#call(event)` — duck-typed, no base class re
50
50
  |-------|--------------|---------------|
51
51
  | `:session_start` | First turn of the session | `{ type: :session_start, session_id: "..." }` |
52
52
  | `:before_turn` | Before each turn starts | `{ type: :before_turn, session_id: "...", prompt: "..." (nil on a continue), messages: [...] (the history before this turn) }` |
53
- | `:after_turn` | After a turn completed or was cancelled (not after one that failed) | `{ type: :after_turn, status: "completed" \| "canceled", messages: [...] (the conversation the turn stored; a cancelled or empty turn ends it with a `kind: turn_note` system message, and a context line is `kind: context`, see [sessions.md](sessions.md#notes-a-turn-leaves-for-the-model)) }` |
53
+ | `:after_turn` | After a turn completed or was cancelled (not after one that failed) | `{ type: :after_turn, status: "completed" \| "canceled", present: (see [Presenting the answer](#presenting-the-answer-display-only)), messages: [...] (the conversation the turn stored; a cancelled or empty turn ends it with a `kind: turn_note` system message, and a context line is `kind: context`, see [sessions.md](sessions.md#notes-a-turn-leaves-for-the-model)) }` |
54
54
  | `:before_generation` | Before each LLM API call (both loops) | `{ type: :before_generation, iteration: N }` |
55
55
  | `:after_generation` | After LLM returns (both loops) | `{ type: :after_generation, iteration: N, response: "...", messages: [...] (the conversation as sent) }` |
56
56
  | `:before_tool_call` | Before tool dispatch (and before `tool_call_started`) | `{ type: :before_tool_call, iteration: N, call: {...}, params: "...", guardrail: Verdict, context: {...}, targets: {...}, blocked: false, block_reason: nil }` |
@@ -59,7 +59,7 @@ The plugin class must respond to `#call(event)` — duck-typed, no base class re
59
59
 
60
60
  Every event also carries the hook runtime (next section): `hook:` (the label
61
61
  of the hook about to run) and the callables `notify:`, `ask_user:`,
62
- `stop_turn:`.
62
+ `stop_turn:`, `steer:`.
63
63
 
64
64
  `messages:` is a **read-only copy**: a frozen array of copied message hashes
65
65
  (`{role:, content:, …}`). A hook that mutates it, or its strings, gets
@@ -69,8 +69,8 @@ cheap).
69
69
  ## What a hook can do: the runtime
70
70
 
71
71
  Besides reading (and, on `:before_tool_call`, voting on) its event, a hook
72
- can talk to the user through three callables the registry puts on every
73
- event:
72
+ can talk to the user, and to the running turn, through four callables the
73
+ registry puts on every event:
74
74
 
75
75
  ```ruby
76
76
  class Watchful
@@ -94,6 +94,14 @@ class Watchful
94
94
  # that call, and the rest of the batch is denied; from :after_turn or
95
95
  # :session_end it does nothing (false).
96
96
  event[:stop_turn].call("too many iterations without progress") if event[:iteration] > 20
97
+ when :after_tool_call
98
+ # Put text into the running turn, as the user's steering does: at the
99
+ # loop's next boundary it joins the conversation as its own user
100
+ # message, and every UI shows a nudge line ("<bundle> nudged: …").
101
+ # True when queued; false with no turn, and from :after_turn or
102
+ # :session_end. A steer that arrives after the model's final answer is
103
+ # dropped (logged), not merged: it never keeps a finished turn going.
104
+ event[:steer].call("Say briefly what you have found so far.") if event[:iteration] == 30
97
105
  end
98
106
  end
99
107
  end
@@ -103,11 +111,55 @@ end
103
111
  known-names)` for a bundle hook, `audit.rb (config)` for a config hook,
104
112
  `turn hook` for one registered at runtime.
105
113
 
114
+ A steer is saved in the session as `{role: "user", kind: "steer", source:
115
+ "<bundle>", content: "…"}`; the model reads only its text, as a user turn.
116
+ Its `source` is the hook's bundle (a config or turn hook's label otherwise).
117
+
106
118
  Timing: a notice from `:after_turn` or `:session_end` shows after the turn's
107
119
  end line. A question from `:before_tool_call` shows **before** the tool
108
120
  line (the gate runs first), so its text should name the call. The notices
109
121
  are also logged (`turn` tag, `hook_notice`).
110
122
 
123
+ ## Presenting the answer (display only)
124
+
125
+ `:after_turn` carries one more callable, `present:`. It changes how the
126
+ turn's answer is **shown**, never what the model said: the block gets the
127
+ current display text (the answer's content until a hook changed it) and
128
+ returns the new one.
129
+
130
+ ```ruby
131
+ class Shout
132
+ def call(event)
133
+ return unless event[:type] == :after_turn
134
+
135
+ event[:present].call { |text| text.gsub(/\bTODO\b/, "**TODO**") }
136
+ end
137
+ end
138
+ ```
139
+
140
+ - The result is kept as `display` on the answer's model message in the
141
+ session file. The model never sees it: the prompts and chat requests take
142
+ the fields they send, and the copies of the conversation given to hooks
143
+ (`messages:`), plugins (`ctx.messages`) and the recap leave it out.
144
+ - Calls chain in hook order (bundle hooks by priority, then config hooks,
145
+ then turn hooks): each block gets what the one before returned. The call
146
+ returns the display text after it.
147
+ - A block that raises, returns something other than a String, or returns
148
+ more than 200 000 characters leaves the display as it was (logged as
149
+ `present_rejected` with the hook's label).
150
+ - It works on the stored conversation's last message only when that is the
151
+ model's answer: after a cancelled, failed or empty turn there is none, and
152
+ the call returns nil without running the block.
153
+ - **The web** renders `display` instead of the answer (markdown, sanitised
154
+ like every answer: raw HTML is escaped, only http(s)/mailto links are
155
+ kept), on a live turn and after a reload. Its copy button copies the
156
+ display text. The page learns about it from an `answer_display` event
157
+ that comes after `turn_completed` (the hooks run after the turn ended).
158
+ - **Terminals** (the REPL, the attached TUI) have printed the answer by then
159
+ and do not change it; use `event[:notify]` for something they should show.
160
+
161
+ A plugin gets the same from `chi.on(:after_turn) { |event, ctx| event[:present].call { … } }`.
162
+
111
163
  ## Settings
112
164
 
113
165
  A hook class whose `initialize` takes an argument gets its settings: **one
@@ -299,7 +351,7 @@ Notes:
299
351
  - Ordering: bundle hooks fire by `(priority, bundle_name, hook_name)` (lower priority first), then plain `config.yml` hooks in registration order.
300
352
  - Settings: a hook class with `initialize(settings = {})` gets the bundle's section of `config.yml` `bundles:` (see [Settings](#settings)).
301
353
  - A bundle can also ship a `plugin.rb` whose `chi.on(event)` blocks are bundle hooks too, next to commands and tools; see [Plugins](plugins.md).
302
- - Shipped bundles: `chi bundle install guardrails` (rules, see [Guardrails](guardrails.md#the-guardrails-bundle)) and `chi bundle install known-names` (a hook, see [Guardrails](guardrails.md#the-known-names-bundle)), `chi bundle install btw` (a plugin: `/btw`, see [Plugins](plugins.md#the-btw-bundle)), `chi bundle install mcp` (a plugin: tools from MCP servers, see [Plugins](plugins.md#the-mcp-bundle)) and `chi bundle install loop-guard` (a plugin: breaks tool-call loops, see [Plugins](plugins.md#the-loop-guard-bundle)).
354
+ - Shipped bundles: `chi bundle install guardrails` (rules, see [Guardrails](guardrails.md#the-guardrails-bundle)) and `chi bundle install known-names` (a hook, see [Guardrails](guardrails.md#the-known-names-bundle)), `chi bundle install source-links` (a hook: announces source refs, see [The source-links bundle](#the-source-links-bundle)), `chi bundle install btw` (a plugin: `/btw`, see [Plugins](plugins.md#the-btw-bundle)), `chi bundle install mcp` (a plugin: tools from MCP servers, see [Plugins](plugins.md#the-mcp-bundle)) `chi bundle install loop-guard` (a plugin: breaks tool-call loops, see [Plugins](plugins.md#the-loop-guard-bundle)) and `chi bundle install check-in` (a plugin: checks on a long turn, see [Plugins](plugins.md#the-check-in-bundle)).
303
355
  - Installing a bundle executes its hook code at `Engine` startup. Only install bundles you trust, as you would a gem. Hooks are **not** executed at install time (copy-only); they are `module_eval`'d at `Engine.new` inside per-bundle `Samagotchi::Bundles::<name>` namespaces (no top-level `require` collisions). Keep hook files side-effect-free at load time; do work in `#call` — top-level side effects (require, IO, `at_exit`, global assignment) run once per `Engine.new` (class redefinition is idempotent).
304
356
 
305
357
  Lifecycle:
@@ -307,3 +359,154 @@ Lifecycle:
307
359
  - `chi bundle install <source>` copies `hooks/*.rb` to `~/.config/samagotchi/memories/.bundles/<name>/hooks/` and persists metadata + `trust_level` + `source_commit` (git HEAD) to provenance.
308
360
  - `Engine.new` loads `config.yml` hooks first, then bundle hooks via `Provenance.each_installed_holding_hooks` → `Hooks::BundleLoader.load`. Bundle hooks are process-scoped (they survive the per-turn `clear_hooks`; only plain hooks are cleared). Experimental bundles emit a one-line startup warning.
309
361
  - `chi bundle status`, `diff`, `uninstall`, `build` are hook-aware (counts, metadata, removal).
362
+
363
+ ## The source-links bundle
364
+
365
+ ```sh
366
+ chi bundle install source-links
367
+ ```
368
+
369
+ installs one `after_turn` hook and a short memory. When the model's answer
370
+ mentions a known source ref — a JIRA ticket, a GitHub issue, an internal
371
+ wiki page — the web links it in the answer (below), and every UI shows one
372
+ line right after the message:
373
+
374
+ ```
375
+ sources: JIRA JIRA-123 → https://myjira.com/browse/JIRA-123, JIRA JIRA-10 → https://myjira.com/browse/JIRA-10
376
+ ```
377
+
378
+ The note is **not part of the conversation**: it is an event shown to the
379
+ user, never stored in the session file. A UI replays it while the session's
380
+ worker lives (a page reload keeps it; a stopped worker loses it). Only the
381
+ model's final answer is scanned (the last `role: "model"` message), and only
382
+ the first 20 000 characters of it. A ref that is already a link is skipped —
383
+ inside a bare URL (`https://x.com/JIRA-123`), in a markdown link's target, or
384
+ in a markdown link's label when the target names the same ref
385
+ (`[JIRA-123](https://x.com/JIRA-123)`) — while `see https://x.com JIRA-123`
386
+ and `[fix for JIRA-123](https://github.com/o/r/pull/9)` still link. A ref
387
+ glued to URL punctuation (`/browse/JIRA-1`, `?key=JIRA-1`, `JIRA-1/foo`) is
388
+ skipped too; `Ticket:JIRA-5` and `#JIRA-123` are ordinary plain text and do
389
+ link. The line names each URL once (compared case-insensitively: `#12` and
390
+ `dm1try/samagotchi#12` may be one issue, and with `case_insensitive: true`
391
+ `JIRA-1` and `jira-1` are one ticket), in first-occurrence order, whatever
392
+ order the sources are configured in. With no sources configured the hook is a
393
+ silent no-op.
394
+
395
+ ```yaml
396
+ bundles:
397
+ source-links:
398
+ sources:
399
+ - name: JIRA
400
+ prefix: JIRA # simple form: \bJIRA-(\d+)\b
401
+ base_url: https://myjira.com/browse/
402
+ - name: GitHub
403
+ pattern: '\bGH-(\d+)\b' # full form: a regex
404
+ url: 'https://github.com/org/repo/issues/{match}'
405
+ case_insensitive: false # optional, default false
406
+ max: 10 # optional: refs per line, default 10
407
+ note: false # optional: no sources line, default true
408
+ ```
409
+
410
+ **In the web** the refs are also links in the answer itself: each
411
+ occurrence becomes `[JIRA-123](https://myjira.com/browse/JIRA-123)` through
412
+ [`event[:present]`](#presenting-the-answer-display-only), so the model's
413
+ text stays as it was, and the links survive a reload and a stopped worker
414
+ (they are the answer's `display` in the session file). The same skip rules
415
+ apply, and a ref in code (a `` `span` `` or a fenced block) or anywhere in a
416
+ markdown link is left as it is; past the first 20 000 characters the answer
417
+ is unchanged. The terminals see only the line; `note: false` drops it and
418
+ keeps the web links.
419
+
420
+ The `prefix:` form compiles to `\b<prefix>-(\d+)\b` and the URL is
421
+ `base_url` + the full ref text (`JIRA-123`). The `pattern:` form takes a
422
+ regex and a `url:` template with placeholders (below). `case_insensitive:
423
+ true` adds the `/i` flag. Past `max` refs the line ends with `… +N more`.
424
+
425
+ Each regex is compiled with a per-regex timeout (0.5 s, per match attempt),
426
+ so a catastrophic pattern is abandoned instead of hanging the turn: that
427
+ source is skipped whole (its partial matches are discarded) with a warning,
428
+ and the others still report. An entry with neither `prefix:` nor `pattern:`,
429
+ or a pattern that does not compile, is skipped with a warning at load. The
430
+ hook is `on_error: log`: a bug in it warns and the turn is unaffected. As with
431
+ every bundle hook, a running worker picks it up after its next start.
432
+
433
+ ### Placeholders, and the project's own repo
434
+
435
+ A `url:` template (the `pattern:` form only; `base_url:` is always
436
+ `base_url` + the ref) can use:
437
+
438
+ | placeholder | value | when it can't be filled |
439
+ |---|---|---|
440
+ | `{match}` | the first capture group, else the whole ref | never: it falls back to the ref |
441
+ | `{1}` … `{9}` | a numbered capture group | the group didn't take part: the ref is **not linked** |
442
+ | `{name}` | a named capture group `(?<name>…)` | the group didn't take part: **not linked** |
443
+ | `{repo}`, `{host}` | the named group `repo` / `host` when the pattern has one and it took part; else the project's git remote | neither: **not linked** |
444
+
445
+ A ref with a placeholder that can't be filled is left out of both the line
446
+ and the answer: no URL is built with a hole in it (another source on the same
447
+ ref can still link it). A `{word}` or `{N}` that is none of the above (not a
448
+ group of the pattern, or `{7}` in a two-group pattern) stays as literal text,
449
+ with one warning when the source is loaded. Every value is percent-encoded
450
+ (everything outside `A-Za-z0-9-._~`, `/` included), except that `{repo}`
451
+ keeps its `/` between segments (GitLab's `group/sub/proj`); a `{repo}` with an
452
+ empty, `.` or `..` segment counts as unfilled.
453
+
454
+ So one source links both `#12` in this project and a cross-repo ref:
455
+
456
+ ```yaml
457
+ bundles:
458
+ source-links:
459
+ sources:
460
+ - name: GitHub
461
+ # `#12` → this project's repo; `owner/repo#12` → that repo
462
+ pattern: '(?<![\w/&])(?:(?<repo>[A-Za-z0-9][\w-]*/[\w.-]*\w))?#(?<num>\d+)\b'
463
+ url: 'https://github.com/{repo}/issues/{num}'
464
+ remote_host: github.com
465
+ ```
466
+
467
+ ```
468
+ sources: GitHub #12 → https://github.com/dm1try/samagotchi/issues/12, GitHub rails/rails#5 → https://github.com/rails/rails/issues/5
469
+ ```
470
+
471
+ GitHub redirects `/issues/N` to `/pull/N` and back, so one URL covers issues
472
+ and pull requests. The lookbehind keeps `&#123;`, `x/#1` and a partial
473
+ `b/c#1` inside `a/b/c#1` out; code and URLs are skipped as always. `PR #12`
474
+ links, `PR#12` doesn't (a `#` right after a letter). The pattern is loose on
475
+ purpose: a bare `#\d+` also matches "step #2", and `and/or#5` or `TCP/IP#3`
476
+ read as qualified refs. A stricter variant wants `PR #`, `issue #` or a
477
+ qualified ref:
478
+
479
+ ```yaml
480
+ pattern: '(?<![\w/&])(?:(?<repo>[A-Za-z0-9][\w-]*/[\w.-]*\w)#|\b(?:PR|[Ii]ssue) #)(?<num>\d+)\b'
481
+ ```
482
+
483
+ **Where `{repo}` and `{host}` come from.** Without a named group that took
484
+ part, they come from `git remote get-url <remote>` run in the session's
485
+ working directory (the worker's; the in-process REPL's is the terminal's
486
+ current directory). `remote:` picks the remote, default `origin` — a fork
487
+ sets `remote: upstream`. Git applies `insteadOf` rewrites and includes, and a
488
+ worktree reports its main repo's remote. The URL forms understood are
489
+ `https://`, `http://`, `ssh://`, `git://` (credentials and port dropped) and
490
+ scp-like `[user@]host:owner/repo`; the repo is the path without a trailing
491
+ `.git` or `/`. A local path, `file://`, no git, no repo or no such remote
492
+ leaves the ref unlinked, silently (a debug log line only). Git is asked only
493
+ when a hit needs it — a JIRA source or a qualified ref never runs it — and
494
+ the answer, even "none", is remembered for the worker's life: a remote
495
+ changed mid-session counts after the worker's next start.
496
+
497
+ `remote_host:` (a host or a list, compared case-insensitively) applies the
498
+ remote-derived links only when the remote's host is one of them, so a
499
+ `https://github.com/{repo}/…` template never points a GitLab project's `#12`
500
+ at github.com. A qualified ref is linked whatever the local remote is. An SSH
501
+ alias (`git@github-work:o/r.git` from a multi-account `~/.ssh/config`) or an
502
+ `insteadOf` mirror reports its own host; list it too:
503
+ `remote_host: [github.com, github-work]`.
504
+
505
+ Two Ruby regex notes. With named groups in a pattern, a plain `(…)` doesn't
506
+ capture and gets no number, so `{1}` is the first *named* group, and so is
507
+ `{match}` (in the example above `{match}` is the repo part): use either named
508
+ or numbered groups in one pattern, and named placeholders with named groups.
509
+ And a pattern with a `host` (or `repo`) group lets the model's text choose the
510
+ link's domain (or repo): the escaping rules out URL injection, but the choice
511
+ of the target is the model's.
512
+
data/docs/plugins.md CHANGED
@@ -305,7 +305,9 @@ end
305
305
 
306
306
  This is a bundle hook, the same as a `hooks/*.rb` file. See
307
307
  [hooks.md](hooks.md#hook-events) for the events and for what `event[:notify]`,
308
- `event[:ask_user]` and `event[:stop_turn]` do. Its label is
308
+ `event[:ask_user]`, `event[:stop_turn]` and `event[:steer]` do; on `:after_turn`,
309
+ `event[:present]` sets how the answer is shown in the web
310
+ ([Presenting the answer](hooks.md#presenting-the-answer-display-only)). Its label is
309
311
  `plugin.rb (bundle my-bundle)`. The block may take only the event. If it
310
312
  raises, the error is logged and the hook is skipped.
311
313
 
@@ -435,6 +437,8 @@ session's life, and each read gives the session as it is now.
435
437
  | `ctx.card(title:, body: "", actions: [], level: :info, id: nil)` | a card in every UI, returning its id: see [Cards](#cards) |
436
438
  | `ctx.ask_user(question:, options:, header: nil, allow_freeform: false)` | a question, like a hook's `event[:ask_user]` |
437
439
  | `ctx.cancelled?` | whether the running turn was cancelled (a long tool should stop) |
440
+ | `ctx.steer(text)` | put text into the running turn, like a hook's `event[:steer]`: its own user message at the loop's next boundary, shown as `my-bundle> nudged: …`. Returns true when queued, false with no turn running (it never starts one; that is `ctx.sessions`' send). Dropped (logged) if the model answers or the turn ends first. Safe from any thread: an anytime command, a hook, your own |
441
+ | `ctx.stop_turn(reason)` | stop the running turn after a warn notice with the reason, like a hook's `event[:stop_turn]`; true when it stopped one now |
438
442
  | `ctx.ask_model(messages:, prompt:, …)` | a side answer from the session's model: see [Side answers](#side-answers-ctxask_model) |
439
443
  | `ctx.sessions` | fork, send to and read other sessions: see [Other sessions](#other-sessions-ctxsessions) |
440
444
 
@@ -460,7 +464,11 @@ ctx.card(id: id, title: "Build finished", body: "no warnings left") # replaces
460
464
  - `actions:` are up to 6 `{label:, command:}`. A command is a line the
461
465
  session runs as if the user typed it: `/hello again`, `/model x`, a
462
466
  plugin's own command. The web shows a button; the terminal shows
463
- `→ /hello again`, to type.
467
+ `→ /hello again`, to type. A button's command leaves no echo: the web
468
+ sends it with `card: true` (`POST /api/sessions/:id/command`), its
469
+ `command_queued` and `command_ran` carry `card: true`, and no UI shows
470
+ its line; the web shows a bubble only when the command answers with
471
+ text or fails.
464
472
  - `level:` is `:info` or `:warn` (the warning colour).
465
473
  - `id:` names an earlier card to replace. Without one a new id is made. The
466
474
  web updates the card in place; the terminal prints it again, marked
@@ -748,6 +756,64 @@ Not caught (yet):
748
756
  - alternating calls (A, B, A, B) that each return something new;
749
757
  - thinking that goes in circles inside one long generation.
750
758
 
759
+ ## The check-in bundle
760
+
761
+ `chi bundle install check-in` installs the bundle shipped with chi. It is
762
+ written only against this API (`lib/samagotchi/bundles/check-in/plugin.rb`):
763
+ `chi.on` hooks, `ctx.card`, `ctx.steer`, `ctx.stop_turn` and one anytime
764
+ command. It has no memory file.
765
+
766
+ A turn can run a long time on its own, reading and searching, without saying
767
+ what it has found. check-in counts the turn's tool calls, and when there are
768
+ `after` of them with no answer yet (then every `every` more) it checks in:
769
+
770
+ - **`mode: ask`** (the default): a card, one per turn, updated in place at
771
+ each check-in: "50 tool calls, no answer yet", how long the turn has run
772
+ and its last few tools, and three actions:
773
+ - **Nudge** (`/checkin nudge`): puts `message` into the running turn
774
+ (`ctx.steer`); the model reads it at its next step and answers or says
775
+ what is left. Every UI shows `check-in> nudged: …`.
776
+ - **Keep going** (`/checkin later`): closes the card until the next
777
+ check-in.
778
+ - **Stop** (`/checkin stop`): stops the turn.
779
+
780
+ When the turn ends the card loses its actions ("The turn ended after N tool
781
+ calls"), so no stale buttons stay. In the attached terminal the actions are
782
+ `→ /checkin nudge` lines to type; the command runs beside the turn.
783
+ - **`mode: nudge`**: nudges the model by itself, with a notice line.
784
+ - **`mode: notify`**: a notice line only.
785
+
786
+ The count is per turn: a new turn (a prompt, a continue, a reminder) starts
787
+ from zero; steering merged into the running turn doesn't reset it. The polling
788
+ tools are not counted. A nudge that arrives after the model's final answer is
789
+ dropped (logged), never restarting a turn that is done; the card's "Nudged the
790
+ model at N tool calls" then becomes "The answer came first; nudge not sent."
791
+
792
+ ```yaml
793
+ # config.yml
794
+ bundles:
795
+ check-in:
796
+ after: 50 # tool calls in one turn before the first check-in
797
+ every: 50 # then again every this many more
798
+ mode: ask # ask | nudge | notify
799
+ message: "You've made {calls} tool calls in this turn without answering. Say briefly what you've found so far and what's left, then answer now or continue."
800
+ ignore_tools: [task_wait, task_get, delegate_result]
801
+ ```
802
+
803
+ `{calls}` in `message` is the count. The default asks for what has been found
804
+ and what is left, not "how's it going?": a small model tends to answer that
805
+ with a line and carry on unchanged.
806
+
807
+ `/checkin` (anytime) for this session, until its worker restarts:
808
+
809
+ | | |
810
+ |---|---|
811
+ | `/checkin` | on or off, the mode, the threshold and this turn's count |
812
+ | `/checkin on` / `off` | check in, or not |
813
+ | `/checkin 30` | check in after 30 tool calls, then every 30 |
814
+ | `/checkin mode ask` / `nudge` / `notify` | the mode |
815
+ | `/checkin nudge` / `later` / `stop` | the card's actions, also by hand |
816
+
751
817
  ## Shutdown
752
818
 
753
819
  When the REPL exits, or a session's worker exits (an idle exit, `/exit`, a
data/docs/releasing.md CHANGED
@@ -15,13 +15,13 @@ can run the whole release; the user approves the notes before the tag and the
15
15
  - **Every other shipped bundle** (btw, guardrails, known-names, loop-guard,
16
16
  mcp) has its own semver in its `manifest.yml` and, when it needs a newer chi,
17
17
  a `requires_chi:` line. They never upgrade by themselves: users run
18
- `chi bundle upgrade NAME`. So:
18
+ `chi update` (all of them, with the gem) or `chi bundle upgrade NAME`. So:
19
19
  - a change to a bundle's files bumps that bundle's `version:`
20
20
  (`rake bundles:check` fails otherwise, once there's a tag to compare with);
21
21
  - a bundle that uses something new in chi raises its `requires_chi` in the
22
22
  same change; `release:bump` never touches `requires_chi`;
23
- - the release notes list the `chi bundle upgrade NAME` steps for every
24
- bundle whose version moved.
23
+ - the release notes say "run `chi update`" and name the bundles whose
24
+ version moved (what changed in each), not per-bundle upgrade steps.
25
25
  - Pre-1.0: config and commands may change in a minor version (0.2 → 0.3); a
26
26
  patch version (0.2.0 → 0.2.1) is fixes only.
27
27
 
@@ -58,9 +58,9 @@ The agent does each step and stops where the user has to say yes.
58
58
  1. **Start from main, up to date and green.** `git checkout main && git pull`;
59
59
  CI on main is green.
60
60
  2. **Draft the notes.** `bundle exec rake release:draft_changelog`, then edit
61
- `## [Unreleased]` in CHANGELOG.md into short user-facing lines. Add the
62
- `chi bundle upgrade NAME` steps for bundles whose version moved since the
63
- last tag (`git diff vPREV -- lib/samagotchi/bundles/*/manifest.yml`).
61
+ `## [Unreleased]` in CHANGELOG.md into short user-facing lines. End with
62
+ "Update with `chi update`", naming the bundles whose version moved since
63
+ the last tag (`git diff vPREV -- lib/samagotchi/bundles/*/manifest.yml`).
64
64
  Pick the version: fixes only → patch, anything else → minor.
65
65
  3. **The user approves the notes and the version.** Show them the section.
66
66
  4. **Bump.** `bundle exec rake "release:bump[X.Y.Z]"`, review `git diff`.
@@ -125,11 +125,21 @@ GitHub release as such (`gh release edit vX.Y.Z --prerelease` or edit its
125
125
  notes: "yanked: …"), add a `### Fixed` line under Unreleased, and release the
126
126
  next patch version.
127
127
 
128
- ## Known CI gaps
128
+ ## CI on Linux
129
129
 
130
- Some examples pass on macOS but fail on Linux CI. They carry `:ci_todo` and are
131
- skipped when `CI` is set: `chi send`/`chi note` subprocess specs (a child
132
- `ruby` without the bundle can't load nokogiri), `--all` on a peer list, the
133
- desktop fields in `chi self` (Linux has no helper), and KernelLoop's
134
- `send_note`. `spec/gem_contents_spec.rb` is skipped on Ruby 3.3 under CI
135
- (`Zlib::BufError` from `Gem::Package.build`). Fix these, then drop the tags.
130
+ The suite runs the same on Linux CI as on macOS, with no CI-only skips. A few
131
+ things differ there, and a new spec that trips on them fails only on CI:
132
+
133
+ - `CI` set marks every new session a test run, and `--all`, `list_sessions`
134
+ and friends leave test runs out. A spec that lists sessions makes them with
135
+ `test_run: false`.
136
+ - The gems live under `vendor/bundle`: a child `ruby` started with a bare env
137
+ (`unsetenv_others: true`) needs `GEM_HOME`/`GEM_PATH` to find nokogiri.
138
+ - Ruby 3.3's zlib raises `Zlib::BufError` when a thread interrupt lands in a
139
+ deflate (ruby/zlib#57, fixed in Ruby 3.4's zlib): build gems in a child
140
+ process, as `spec/gem_contents_spec.rb` does.
141
+
142
+ To run it locally, use Docker with the Ruby build CI uses
143
+ (`ruby-X.Y.Z-ubuntu-24.04-x64.tar.gz` from ruby/ruby-builder's releases) on
144
+ `ubuntu:24.04` with `LANG=C.UTF-8`, `zip` and `git`, and run
145
+ `CI=1 BUNDLE_PATH=… bundle exec rspec`.
data/docs/sessions.md CHANGED
@@ -23,6 +23,8 @@ A session is deleted if **expired by age OR overflow by count** (unless `keep_st
23
23
 
24
24
  **Deleting a session:** `chi sessions delete ID...`, `/exit --delete` in a terminal and the Web UI's delete all go through `SessionManager.delete_session`: it resolves a unique prefix, removes `<id>.json` and the whole `<id>/` dir, and returns what it removed. A session a plain REPL has open (`owner.lock` kind `tui`) is always refused. One a worker runs is refused unless the caller asks to stop it: then it stops the worker as `chi sessions stop` does and deletes once `owner.lock` is free (`--force` on the CLI, 10 s; the web always, 2 s; `/exit --delete` after the worker agreed to exit, 10 s). A worker that outlives the wait leaves the session in place. The web's route is `DELETE /api/sessions/:id` (200 `{status: "deleted", session_id, stopped}`; 409 `owned_by_tui` or `still_stopping`; 404).
25
25
 
26
+ <a id="archiving-a-session"></a>**Archiving a session:** `chi sessions archive ID...`, `/archive` in a terminal and the Web UI's `archive` go through `SessionManager.archive_session`: it writes `<id>/archived` (`{"archived_at": …}`; not a session.json field, which every save rewrites) for the session and its delegated children. `Session.list` leaves archived sessions out unless `include_archived:`, and everything built on it follows: `chi sessions list` (`--archived` shows them, `[archived]`), the summaries (`--live`, json/tsv, the desktop panel, the agent's `list_sessions`) and the retention prune, which neither deletes nor counts them against `max_count`. `children_of` (max_children, `delegate_result`) still sees them, and `--resume`/`--attach ID`, `chi send`/`chi note ID` reach them as before. Refused: a turn running in the session or in one of its children (naming the child; 409 `busy` on the web), a plain REPL owning it (`owned_by_tui`), a `chi scratch` session (`scratch`). A live idle worker is stopped first, and the marker is written even if it is still shutting down. Input a human typed brings a session back (`{"unarchived_at": …}`): a prompt from the web, an attached or plain terminal or `chi send` (origin `web:*`, `tui:*`, `cli:send`, none), steering merged into a running turn, a `/continue` answer or a question answered; a delegate's follow-up, a plugin's turn, a reminder, the idle recap, a note and just opening it don't. A child's input brings only the child back. The retention sweep ages an unarchived session from when it was unarchived, so unarchiving an old session doesn't let the next sweep take it. `/archive` leaves the session first, as `/exit` does, then archives it: in an attached terminal the worker is asked to exit (one that stays up for another UI is stopped by the archive; a running turn refuses it), and an empty session is discarded instead; `chi scratch` refuses it. `chi sessions unarchive ID...` and the web's `unarchive` bring back the whole family. Web routes: `POST /api/sessions/:id/archive` (200 `{status, session_id, archived, stopped, discarded}`, ids) and `POST /api/sessions/:id/unarchive` (200 `{status, session_id, unarchived}`). The session hub and `GET /api/sessions` keep archived sessions, `archived: true` in their summaries: the page leaves them out of the strip, its count and the list at render, and "include archived" by the all-sessions search shows them, dimmed with an `archived` badge. The info bar reads `archive · stop · delete` (`unarchive` on an archived one); archiving the open session stays on it, with an Undo toast.
27
+
26
28
  **Lazy sweep:** automatic prune runs at most once per 24h on `GET /api/sessions` (Web). No background thread or cron. Manual prune is always available.
27
29
 
28
30
  **CLI:**
@@ -31,6 +33,8 @@ A session is deleted if **expired by age OR overflow by count** (unless `keep_st
31
33
  chi sessions list [--sort updated_at|created_at] [--order desc|asc] [--limit N] [--scope=all]
32
34
  chi sessions list [--live] [--cwd PATH] [--limit N] [--format text|json|tsv] [--scope=all]
33
35
  chi sessions stop ID
36
+ chi sessions archive ID... # hide from every list, keep for good; unarchive ID... undoes it
37
+ chi sessions list --archived # archived sessions too, marked [archived]
34
38
  chi sessions delete [--force] ID... # for good; --force stops a live worker first
35
39
  chi sessions prune [--dry-run] [--days N] [--keep N] [--keep-status running,...] [--test-only]
36
40
  chi sessions clean [--dry-run] [--days N] # test sessions: all, or older than N days
@@ -48,11 +52,26 @@ chi sessions clean --dry-run --days 7 # test sessions older than 7 da
48
52
 
49
53
  `--dry-run` is the safe preview. Web has no prune endpoint; use the CLI.
50
54
 
51
- `chi sessions list` shows each session's saved recap, its first sentence without the "The user was…" opening (as on the web cards), in place of its last prompt, cut to 60 characters; a session with no recap shows its last prompt. A delegated session's row ends with `↳ <parent's short id>` (plain and `--live`; `--format json` has `parent_id`); rows keep their `updated_at` order, so a parent may be on another page than its children.
55
+ `chi sessions list` shows each session's saved recap, its first sentence without the "The user was…" opening (as on the web cards), in place of its last prompt, cut to 60 characters; a session with no recap shows its last prompt. A delegated session's row ends with `↳ <parent's short id>` (plain and `--live`; `--format json` has `parent_id`); rows keep their `updated_at` order, so a parent may be on another page than its children. After the status, `ctx 12%` says how full the context was after the session's last counted turn (from its `analytics.json`; blank when no turn had a server count or the window is unknown; `ctx_pct` in `--format json`). The web's session cards show it quietly too (`12%`), and a session's ctx meter shows it on load, before its next turn streams.
52
56
 
53
57
  **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.
54
58
 
55
- `--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. `--live` shows 10 unless `--limit` says otherwise; filters apply before the limit. `--format json` prints `[{id, short_id, desc, cwd, updated_at, live, busy, owner, recap}]` (`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`).
59
+ `--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. `--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`).
60
+
61
+ **Ordering:**
62
+
63
+ - `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.
64
+ - Web UI (`chi web`): the page's scope is in its URL. Started in a git repo, `chi web` opens `/?dir=<that folder>`: that project's sessions, and new chats start in that folder; the header chip says `<project> · all`, and `all` opens the same place without `?dir` (every session; a new chat there starts in the server's own folder, shown on the start page, and cards name their folder; `← <project>` goes back to the project view it came from, or to the server's own project). One server serves every project: a second `chi web` (from another repo) finds it through `GET /api/info` and prints (with `--open`, opens) its page for its own folder instead of starting another. The 3 latest sessions sit above the chat; "All sessions" (or `/`) opens every session at `#/sessions`, with a search over preview, id and status (Esc or Back returns). The open session is in the URL (`#/s/<id>`), so a reload or a copied link opens it again; the chi logo top left goes back to the empty start for a new chat. The message box grows with its text; drag its top edge to keep it taller (double-click resets). The info bar copies `chi --attach <id>` for a terminal. The start page's model picker (bottom left of the composer, `host:model ▾`) chooses the model a new chat starts on: `GET /api/models` lists the hosts' models as chi spells them (`{default, models: [{name, host, id}], warning?}`; bare for the default host, `host:model` for the others, from the same cached lists as `/models`, a bounded wait, a host that is down noted in `warning`), and `POST /api/sessions` takes `model` (blank means the default). The start page's first message goes as later ones do: `POST /api/sessions` with `idle: true` and `preview` (the message, which names the session until its turn is saved), then `POST /api/sessions/:id/turn`, so a failed first turn puts its prompt back in the composer too; a first command line (`/model …`) still starts the session as its `prompt`. The browser remembers the last choice; a running session's model is in the info bar and changes only with `/model`.
65
+ - 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).
66
+ - 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`). Unit-tested via `npm test` (`node --test spec/web/public/*.test.js`).
67
+ - 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).
68
+
69
+ **Test-session hygiene:**
70
+
71
+ - New sessions set `test_run:true` when `SAMAGOTCHI_ENV=test` or `RACK_ENV=test` or `CI` is set (explicit flag, `metadata_version` 2). Old sessions without the flag load as `test_run:false`.
72
+ - Test runs are tagged and obey the same retention. `chi sessions clean` deletes every test session whatever its age (`--days N`: only those older than N days); a live worker or a `keep_status` status still keeps one. `chi sessions prune --test-only` applies the usual age and count rules to test sessions only.
73
+ - A `chi scratch` session (`"scratch": true`) is deleted when its REPL ends; one a killed process left behind is deleted by the next sweep, `prune` or `clean`, whatever its age or status, once nobody owns it. `list` shows it as `[scratch]` (every text form; `scratch: true` in `--format json`); `chi web` never shows one. See [CLI: Scratch sessions](cli.md#scratch-sessions).
74
+ - For ad-hoc manual QA use `SAMAGOTCHI_ENV=test XDG_STATE_HOME=/tmp/chi-test-$USER chi ...` to isolate from real state; the flag also marks sessions that `chi web` or an attached `chi` spawn (their workers inherit the environment), so `clean` finds them if they land in the real state.
56
75
 
57
76
  ## Context notes
58
77
 
@@ -116,8 +135,9 @@ cancel during its thinking, it said it had never been asked.
116
135
  `chi send` is the other half of `chi note`: the text goes in as your message, the same as typing it in the attached terminal or the web composer, so a turn runs.
117
136
 
118
137
  ```sh
119
- chi send [-m TEXT] (ID|PREFIX)...
138
+ chi send [-m TEXT] [--image PATH]... (ID|PREFIX)...
120
139
  chi send -m "is this the same bug?" 3fa2 # a message
140
+ chi send --image shot.png -m "why is this red?" 3fa2 # with an image
121
141
  pbpaste | chi send -m "is this the same bug?" 3fa2 # the clipboard quoted above the message
122
142
  pbpaste | chi send 3fa2 # the clipboard is the message
123
143
  ```
@@ -126,10 +146,31 @@ pbpaste | chi send 3fa2 # the clipboard is the messa
126
146
  - It goes through the same path as the web composer (`SessionManager.deliver_turn`): the worker's Bridge when it is up, so every attached UI shows it as your message (client id `cli:send`, shown like any user message); the input file when the worker is on its way out. During a running turn it is merged into that turn at the next step, like a message typed then.
127
147
  - A session with no worker gets one started (like `--attach` or the web composer). A session open in a plain REPL (`--no-shared`) refuses it. Only sessions on this machine: Bridges listen on 127.0.0.1.
128
148
  - Fire and forget: it returns once the message is queued and never prints the answer; that shows in whatever is attached. A guardrail "ask" waits for a UI to answer it, so with nothing attached the turn stalls there until one attaches (`chi --attach ID`).
129
- - One line per session: `sent`, `sent (the running turn picks it up)`, `sent (started its worker)`, `refused: …` or `failed: …`. Exit 0 when all were sent, 1 when any was refused, failed or not found, 2 for a usage error. There is no `--all`.
149
+ - `--image PATH` (repeatable, up to 20) sends images with the message, as the web composer's chips do. Each file is read once before anything is sent (bmp, tiff and heic converted to png, large ones downscaled, as for `@path`); a missing file or one that isn't an image is a usage error (exit 2) and nothing is sent. Each session gets its own copy in `<session>/images/`. Text is still required (`-m` or stdin; context alone counts), also with `--wait`. The session's model must see images: a text-only one fails the turn in the session (the line here still says `sent`). A running turn doesn't take images mid-turn: the message runs as the next turn, and the line says `(runs after the current turn)`. With `--new` the session starts idle with the message as its preview, then the message goes in as its first turn once its worker is up (`<id> started with 1 image`); if the worker doesn't come up in 5 s the session is kept, with its id on the `failed:` line.
150
+ - One line per session: `sent`, `sent with 2 images`, `sent (the running turn picks it up)`, `sent (started its worker)`, `refused: …` or `failed: …`. Exit 0 when all were sent, 1 when any was refused, failed or not found, 2 for a usage error. There is no `--all`.
130
151
 
131
152
  The Automator action above works for messages too: swap its last line for `pbpaste | "$chi" send -m "what do you make of this?" $(print -r -- "$picked" | cut -f1)`.
132
153
 
154
+ ### Starting a session
155
+
156
+ `chi send --new` starts a new session with the message, the way the web start page does: a worker runs it, so it is in `chi web` and `chi sessions list` at once and streams live there; `chi --attach ID` joins it. `--wait` blocks until the answer and prints it, which makes it an agent's one-shot the user can watch (unlike `chi -p … --non-interactive`, which runs in-process and saves the session only at the end).
157
+
158
+ ```sh
159
+ chi send --new -m "review the diff on feat/x" # prints "<id> started", returns at once
160
+ git diff | chi send --new -m "review this" # stdin is quoted context, as above
161
+ chi send --new --wait -m "review the diff on feat/x" # blocks; stdout is the answer
162
+ chi send --wait -m "and the tests?" 3fa2 # a follow-up in the same session, waits too
163
+ chi send --wait 3fa2 # sends nothing: waits for its next reply
164
+ ```
165
+
166
+ - `--new` takes no ids (one new session per call). `--dir DIR` is its folder, and so its project (default the current one); `--model M` its model (default the configured one; the name isn't checked up front, a wrong one fails the worker's first turn).
167
+ - `--new` prints `<full id> started` on stdout. With `--wait` that line (and the `sent` line for an existing session) goes to stderr, so stdout is exactly the answer: the reply of the turn the message started, in full. It is the worker's `output/` file, the same one `delegate` reads; a turn that ends with only tool calls and no text has none.
168
+ - `--wait` takes one session: `--new` or one id. A session with a running turn is refused (`busy: a turn is running; wait or attach`, exit 1): the message would run after it, and its reply would come back as the answer.
169
+ - Exit codes with `--wait`: 0 answered; 3 the turn waits for an answer from you (a question or a guardrail approval), with the line `waiting for an answer: …; open it: chi --attach ID or the web`, and the session keeps waiting; 1 the turn ended without a reply (canceled, failed or empty), the worker failed or vanished, the session was stopped, or `--timeout S` passed; 130 on Ctrl-C, which leaves the turn running (`still running: chi --attach ID`). There is no default timeout.
170
+ - `--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 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).
171
+ - 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.
172
+ - A session nobody attaches to stalls at its first guardrail ask until someone opens it; left alone it idle-exits after `session.idle_exit_minutes` like any worker. These are ordinary sessions: delete them like any other.
173
+
133
174
  ## Delegating
134
175
 
135
176
  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; nothing checks the host serves it, so an unknown one fails the child's first turn). 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.
@@ -140,16 +181,3 @@ The `delegate` tool hands a task to a **child session**: an ordinary chi session
140
181
  - 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.
141
182
  - 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.
142
183
  - The link is the session's `parent_id`, written before the worker starts, so a respawn keeps it; `GET /api/sessions` and `/api/sessions/:id` carry it, and `SessionManager.children_of` lists a parent's children.
143
-
144
- **Ordering:**
145
-
146
- - `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.
147
- - Web UI (`chi web`): the page's scope is in its URL. Started in a git repo, `chi web` opens `/?dir=<that folder>`: that project's sessions, and new chats start in that folder; the header chip says `<project> · all`, and `all` opens the same place without `?dir` (every session; a new chat there starts in the server's own folder, shown on the start page, and cards name their folder; `← <project>` goes back to the project view it came from, or to the server's own project). One server serves every project: a second `chi web` (from another repo) finds it through `GET /api/info` and prints (with `--open`, opens) its page for its own folder instead of starting another. The 3 latest sessions sit above the chat; "All sessions" (or `/`) opens every session at `#/sessions`, with a search over preview, id and status (Esc or Back returns). The open session is in the URL (`#/s/<id>`), so a reload or a copied link opens it again; the chi logo top left goes back to the empty start for a new chat. The message box grows with its text; drag its top edge to keep it taller (double-click resets). The info bar copies `chi --attach <id>` for a terminal. The start page's model picker (bottom left of the composer, `host:model ▾`) chooses the model a new chat starts on: `GET /api/models` lists the hosts' models as chi spells them (`{default, models: [{name, host, id}], warning?}`; bare for the default host, `host:model` for the others, from the same cached lists as `/models`, a bounded wait, a host that is down noted in `warning`), and `POST /api/sessions` takes `model` (blank means the default). The browser remembers the last choice; a running session's model is in the info bar and changes only with `/model`.
148
- - 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), `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`). Unit-tested via `npm test` (`node --test spec/web/public/*.test.js`).
149
- - 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).
150
-
151
- **Test-session hygiene:**
152
-
153
- - New sessions set `test_run:true` when `SAMAGOTCHI_ENV=test` or `RACK_ENV=test` or `CI` is set (explicit flag, `metadata_version` 2). Old sessions without the flag load as `test_run:false`.
154
- - Test runs are tagged and obey the same retention. `chi sessions clean` deletes every test session whatever its age (`--days N`: only those older than N days); a live worker or a `keep_status` status still keeps one. `chi sessions prune --test-only` applies the usual age and count rules to test sessions only.
155
- - For ad-hoc manual QA use `SAMAGOTCHI_ENV=test XDG_STATE_HOME=/tmp/chi-test-$USER chi ...` to isolate from real state; the flag also marks sessions that `chi web` or an attached `chi` spawn (their workers inherit the environment), so `clean` finds them if they land in the real state.