samagotchi 0.32.0 → 0.33.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 (45) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +29 -1
  3. data/docs/context.md +3 -1
  4. data/docs/hooks.md +6 -3
  5. data/docs/plugins.md +2 -1
  6. data/docs/releasing.md +2 -1
  7. data/lib/samagotchi/answer_command.rb +2 -1
  8. data/lib/samagotchi/archive_store.rb +1 -15
  9. data/lib/samagotchi/bridge.rb +8 -3
  10. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  11. data/lib/samagotchi/child_reports.rb +31 -10
  12. data/lib/samagotchi/client_id.rb +52 -0
  13. data/lib/samagotchi/context_absorber.rb +2 -1
  14. data/lib/samagotchi/context_fetch.rb +14 -45
  15. data/lib/samagotchi/context_sources.rb +20 -10
  16. data/lib/samagotchi/engine.rb +10 -7
  17. data/lib/samagotchi/guardrails/parent_approvals.rb +0 -2
  18. data/lib/samagotchi/memory_bundle/builder.rb +22 -1
  19. data/lib/samagotchi/memory_bundle/manifest.rb +3 -1
  20. data/lib/samagotchi/plugin/sessions.rb +2 -1
  21. data/lib/samagotchi/process_group.rb +160 -0
  22. data/lib/samagotchi/prompt_warmup.rb +20 -1
  23. data/lib/samagotchi/question_desk.rb +2 -1
  24. data/lib/samagotchi/relay_verifier.rb +2 -4
  25. data/lib/samagotchi/reply_wait.rb +9 -4
  26. data/lib/samagotchi/send_command.rb +2 -1
  27. data/lib/samagotchi/session_manager.rb +2 -4
  28. data/lib/samagotchi/steer.rb +27 -19
  29. data/lib/samagotchi/terminal_ui/attached_loop.rb +5 -4
  30. data/lib/samagotchi/terminal_ui/formatting.rb +21 -12
  31. data/lib/samagotchi/terminal_ui.rb +2 -1
  32. data/lib/samagotchi/tools/delegate.rb +2 -2
  33. data/lib/samagotchi/tools/execute.rb +10 -81
  34. data/lib/samagotchi/tools/task_runtime.rb +25 -38
  35. data/lib/samagotchi/version.rb +1 -1
  36. data/lib/samagotchi/web/app.rb +2 -1
  37. data/lib/samagotchi/web/public/app.js +19 -20
  38. data/lib/samagotchi/web/public/data.js +3 -0
  39. data/lib/samagotchi/web/public/question_card.js +3 -4
  40. data/lib/samagotchi/web/public/shown_texts.js +13 -0
  41. data/lib/samagotchi/web/public/stable_html.js +18 -0
  42. data/lib/samagotchi/web/public/turn_events.js +9 -2
  43. data/lib/samagotchi/web/public/turn_view.js +1 -1
  44. data/lib/samagotchi/worker.rb +33 -11
  45. metadata +5 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d9af1ef94626f6ec7c44f8a7a0ad46421eca3c770b9b0bc6dab78664ec3a9cd9
4
- data.tar.gz: bb2db5c510efa064f5c377335d83b6df450a67f0bafa8c70e4160ef97dc2d874
3
+ metadata.gz: ca8acdcb161f8d093ab1c860913dd3a1e826083bcfff0165039847e38a26c5ad
4
+ data.tar.gz: 5c286ada590d84b793e95d9f2dec051132462469aace12c0faf1da0f1c4e3fdf
5
5
  SHA512:
6
- metadata.gz: af61e4ce113e2dea9a3ecf9bedc4d7335aa35f7aca81e37734a952e150f79930e9a6451e1c22c507762caafa76c29fd324fe3544e901cf70fa6783547cf5924e
7
- data.tar.gz: fd9ca3f931a144e48e8a120202000cf845806aaaedb676d3dde3a094e03432bc19f79eadac054363f7248c257bec7fe74ba3ae99f8b682492215dae64a4e7392
6
+ metadata.gz: 394a942740165c9ea457b3a3043a070ef9338a897c6eb41c387b1101e8426c8e21c938757868d87493216324d84441165a82601b796801be90feef5c8e6c7091
7
+ data.tar.gz: 29d24ccdb0bd41d027ccdaf4b8c6e0a191d292b636fc3af1e27e994f30b1331192f59d3bf6aa5d858e78ec5d84d32d8760f2df03c8dace2feaad1ac87783c1c6
data/CHANGELOG.md CHANGED
@@ -8,6 +8,33 @@ and commands may change between minor versions. How releases are made:
8
8
 
9
9
  ## [Unreleased]
10
10
 
11
+ ## [0.33.0] - 2026-10-06
12
+
13
+ ### Changed
14
+
15
+ - Input from a client chi doesn't know (a script calling the web API with its own id, say) is no longer taken
16
+ for the user's: the model reads it as automatic input, not your message, and it never cuts in on a thinking
17
+ model. The TUI and the web label a prompt by its sender: "chi send", "chi answer", "plugin", or "automatic";
18
+ "user" is left for your own lines.
19
+
20
+ ### Fixed
21
+
22
+ - `task_stop` no longer signals a process group chi didn't start: a missing or reused pid in a task record could
23
+ stop chi or other processes.
24
+ - A delegate child that crashes still wakes its idle parent.
25
+ - `chi send <id> --wait` to a stopped session with attached context prints the answer instead of "no answer".
26
+ - `chi bundle build` keeps a bundle's `scripts:` and `context_providers:`, so a rebuilt bundle still attaches its URLs.
27
+ - A context source's wake is no longer lost when a later update that doesn't ask to wake comes in before the
28
+ session reads it.
29
+ - A failed wake turn leaves a plain note in the conversation; a reload no longer draws an empty turn for it.
30
+ - Web: the info bar no longer swallows a click on "copy chi --attach" or Restart while a turn runs, and its
31
+ tooltips stay up.
32
+ - Web: an image the server refuses shows why; a 409 that isn't "open in a chi REPL" no longer switches the page to
33
+ the REPL notice.
34
+ - Web: an answer equal to an earlier turn's ("Done." twice) is shown.
35
+
36
+ Update with `chi update`.
37
+
11
38
  ## [0.32.0] - 2026-10-06
12
39
 
13
40
  ### Added
@@ -1636,7 +1663,8 @@ and long-lived sessions.
1636
1663
  - A macOS desktop helper (`chi desktop install`): a "Send to chi" Service and a
1637
1664
  hotkey panel that send selected text or the clipboard to your sessions.
1638
1665
 
1639
- [Unreleased]: https://github.com/dm1try/samagotchi/compare/v0.32.0...HEAD
1666
+ [Unreleased]: https://github.com/dm1try/samagotchi/compare/v0.33.0...HEAD
1667
+ [0.33.0]: https://github.com/dm1try/samagotchi/compare/v0.32.0...v0.33.0
1640
1668
  [0.32.0]: https://github.com/dm1try/samagotchi/compare/v0.31.0...v0.32.0
1641
1669
  [0.31.0]: https://github.com/dm1try/samagotchi/compare/v0.30.0...v0.31.0
1642
1670
  [0.30.0]: https://github.com/dm1try/samagotchi/compare/v0.29.0...v0.30.0
data/docs/context.md CHANGED
@@ -135,7 +135,9 @@ attaches a URL; the web never adds a command.
135
135
  A change can start a turn by itself, so the agent tells you about it while
136
136
  you're away from the terminal. All of these must hold:
137
137
 
138
- - the source asked (`"wake": true`; a plain-text source never does);
138
+ - the source asked (`"wake": true`; a plain-text source never does), in
139
+ any change since the session last heard of it: a later change that doesn't
140
+ ask doesn't take the wake back;
139
141
  - `context.wake` is on (default `true`);
140
142
  - the session's worker is up and idle: no turn running, nothing queued, no
141
143
  question or continue offer waiting, not just started;
data/docs/hooks.md CHANGED
@@ -122,9 +122,12 @@ Its `source` is the hook's bundle (a config or turn hook's label otherwise).
122
122
  Lines typed into a running turn are saved as `{role: "user", kind: "input",
123
123
  content: "…"}`: part of that turn, not a turn of their own. They carry a
124
124
  `source` when they are not the user's own: `chi_send` (`chi send -m`),
125
- `parent_agent` (a delegate's follow-up) or `plugin_send` (`ctx.sessions.send`).
126
- The model reads them with the same kind of header (`[Steer from the user, …]`,
127
- `[Steer sent with chi send, …]`, and so on).
125
+ `parent_agent` (a delegate's follow-up), `plugin_send` (`ctx.sessions.send`),
126
+ or `automatic:<client id>` for a client id chi doesn't know (an unknown sender
127
+ is never taken for the user). The model reads them with the same kind of
128
+ header (`[Steer from the user, …]`, `[Steer sent with chi send, …]`, and so
129
+ on; `[Automatic input from <client id>, sent mid-task; not your user's
130
+ message. …]` for an unknown one).
128
131
 
129
132
  Timing: a notice from `:after_turn` or `:session_end` shows after the turn's
130
133
  end line. A question from `:before_tool_call` shows **before** the tool
data/docs/plugins.md CHANGED
@@ -1181,7 +1181,8 @@ gem.
1181
1181
  - `chi bundle diff <name> [plugin.rb]`: shows the installed base and the
1182
1182
  file on disk.
1183
1183
  - `chi bundle build --name <name>`: puts the installed plugin (and
1184
- `requires_chi`) into the built bundle.
1184
+ `requires_chi`, its `scripts/` and `context_providers:`) into the built
1185
+ bundle.
1185
1186
  - `chi bundle uninstall <name>`: removes the plugin with the bundle.
1186
1187
 
1187
1188
  ## Not yet
data/docs/releasing.md CHANGED
@@ -49,7 +49,8 @@ as the GitHub release body.
49
49
  | Task | What it does |
50
50
  | --- | --- |
51
51
  | `rake bundles:sha` | Recomputes the sha256 lines of every shipped bundle manifest (`files:`, `hooks:`, `plugin:`). |
52
- | `rake bundles:check` | Sha lines match; a bundle changed since the last `v*` tag has a higher version (skipped, with a note, before the first tag). Warns (doesn't fail) when such a bundle's `requires_chi` still admits the tagged chi: raise it if the change uses anything new. |
52
+ | `rake bundles:check` | Sha lines match; a bundle changed since the last `v*` tag has a higher version (skipped, with a note, before the first tag). Warns (doesn't fail) when such a bundle's `requires_chi` still admits the tagged chi: raise it if the change uses anything new. The release workflow and `release:check` run it in full. |
53
+ | `rake 'bundles:check[ci]'` | The CI workflow's (every push and PR): the same, except the version check leaves out the system bundle, which is changed and unbumped between releases (`release:bump` moves its version with VERSION). A task argument, not `CI=1`: GitHub sets `CI` in the release workflow too. |
53
54
  | `rake release:draft_changelog` | A draft Unreleased section from the commit subjects since the last tag, grouped. Stdout only. |
54
55
  | `rake release:bump[X.Y.Z]` | VERSION, the system manifest, Gemfile.lock, CHANGELOG. No commit. |
55
56
  | `rake release:check` | Clean tracked tree, VERSION == system bundle, a CHANGELOG section, `bundles:check`, `rake lint` (RuboCop), rspec + npm test, `gem build`, then a clean install into a temp GEM_HOME that runs `chi --version`, `chi self` and `chi bundle list` with a temp HOME. Needs the network (gem dependencies). |
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "client_id"
3
4
  require_relative "session"
4
5
  require_relative "session_manager"
5
6
  require_relative "bridge_client"
@@ -189,7 +190,7 @@ module Samagotchi
189
190
  client.dismiss_question(id: qid)
190
191
  else
191
192
  client.answer(id: qid, selected: selected, freeform: options[:text],
192
- client_id: Guardrails::ParentApprovals::CLIENT_ID)
193
+ client_id: ClientId::CLI_ANSWER)
193
194
  end
194
195
  break unless reply.status == 408
195
196
  end
@@ -17,12 +17,6 @@ module Samagotchi
17
17
  # file from memory (and bumps updated_at) at every turn.
18
18
  module ArchiveStore
19
19
  FILE = "archived"
20
- # Input a human typed: a web tab, a chi TUI, `chi send`, and nil (a
21
- # worker's initial prompt; a web client may send none). Delegates,
22
- # plugins and reminders are not (an allowlist, so a new automatic
23
- # origin stays out).
24
- USER_CLIENT_PREFIXES = %w[web: tui:].freeze
25
- USER_CLIENT_IDS = ["cli:send"].freeze
26
20
 
27
21
  # @return [Hash, nil] the marker (string keys), nil when none or unreadable
28
22
  def self.read(session_dir)
@@ -57,15 +51,7 @@ module Samagotchi
57
51
  write(session_id, { "unarchived_at" => Time.now.iso8601(3) }, state_dir: state_dir)
58
52
  end
59
53
 
60
- # Whether +client_id+ (a turn's origin) is a human's input.
61
- def self.user_input?(client_id)
62
- return true if client_id.nil?
63
-
64
- id = client_id.to_s
65
- USER_CLIENT_IDS.include?(id) || USER_CLIENT_PREFIXES.any? { |prefix| id.start_with?(prefix) }
66
- end
67
-
68
- # A human's input came into the session: it is back in the lists. Its
54
+ # A human's input (ClientId.human?) came into the session: it is back in the lists. Its
69
55
  # children stay archived. Never raises.
70
56
  # @return [Boolean] whether it was archived
71
57
  def self.user_input(session_id, state_dir:)
@@ -55,6 +55,8 @@ module Samagotchi
55
55
  CANCEL_REASONS = %w[manual user ctrl_c].freeze
56
56
  # How long #stop waits for requests it is answering (not open streams).
57
57
  REQUEST_GRACE_SECONDS = 1.0
58
+ # How long #stop waits for each killed RelayWatcher thread to end.
59
+ RELAY_WATCHER_JOIN_SECONDS = 1.0
58
60
  # The largest request body read (images travel as refs, never bytes).
59
61
  MAX_BODY_BYTES = 1_000_000
60
62
  # A request's deadline (see #handle_post_turn) that isn't epoch seconds.
@@ -198,7 +200,9 @@ module Samagotchi
198
200
  await_answers(REQUEST_GRACE_SECONDS)
199
201
  @connection_threads.each { |t| t.kill rescue nil }
200
202
  @connection_threads.clear
201
- @mutex.synchronize { @relay_watchers&.each { |t| t.kill rescue nil } }
203
+ # Its relay watchers (RelayWatcher) end with it.
204
+ watchers = @mutex.synchronize { (@relay_watchers || []).tap { @relay_watchers = [] } }
205
+ watchers.each { |thread| thread.kill.join(RELAY_WATCHER_JOIN_SECONDS) }
202
206
  @accept_thread&.join(2)
203
207
  remove_sidecar
204
208
  nil
@@ -839,8 +843,9 @@ module Samagotchi
839
843
  return [{}, 500, { error: "enqueue_failed", detail: "could not write turn input" }] unless enqueued
840
844
 
841
845
  # Queued (outside the event lock): a running generation that has only
842
- # been thinking for long is cut for it, unless a plugin sent it. An
843
- # image input never merges mid-turn, so it never cuts.
846
+ # been thinking for long is cut for it, unless a plugin or a client
847
+ # chi doesn't know sent it. An image input never merges mid-turn, so
848
+ # it never cuts.
844
849
  @engine.cut_for_steer(Steer.source_for_client(client_id), epoch: epoch) if images.empty?
845
850
  [{}, 202, { status: "accepted", enqueued_id: enqueued_id, session_id: @session_id }]
846
851
  end
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: samagotchi-system
3
- version: 0.32.0
3
+ version: 0.33.0
4
4
  scope: system
5
5
  description: Default system memories — identity, self map, config modification protocol, memory guide and the delegated-session rules
6
6
  files:
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "fileutils"
4
+ require_relative "client_id"
4
5
  require_relative "session"
5
6
  require_relative "session_inbox"
6
7
  require_relative "reply_wait"
@@ -29,6 +30,13 @@ module Samagotchi
29
30
  # Turns an idle parent runs for reports in a row with no human input.
30
31
  MAX_WAKES_KEY = "session.max_wakes"
31
32
  MAX_WAKES_DEFAULT = 10
33
+ # How long a leaving worker waits for the parent wakes it started
34
+ # (#await_wakes): a wake only spawns a worker, well under a second.
35
+ WAKE_JOIN_SECONDS = 5
36
+
37
+ # The wake threads #ring started in this process, until #await_wakes.
38
+ @wakes = []
39
+ @wakes_mutex = Mutex.new
32
40
 
33
41
  module_function
34
42
 
@@ -70,15 +78,29 @@ module Samagotchi
70
78
  def wake_parent(parent_id, state_dir:, wake:)
71
79
  return if SessionManager.session_owner(parent_id, state_dir: state_dir)
72
80
 
73
- if wake
74
- wake.call(parent_id)
75
- else
76
- Thread.new do
77
- SessionManager.wake_for_report(parent_id, state_dir: state_dir)
78
- rescue StandardError => e
79
- Log.warn(:worker, "delegate_wake_failed", parent: parent_id[0, 8], error: e.class.name, msg: e.message)
80
- end
81
+ return wake.call(parent_id) if wake
82
+
83
+ thread = Thread.new do
84
+ SessionManager.wake_for_report(parent_id, state_dir: state_dir)
85
+ rescue StandardError => e
86
+ Log.warn(:worker, "delegate_wake_failed", parent: parent_id[0, 8], error: e.class.name, msg: e.message)
81
87
  end
88
+ @wakes_mutex.synchronize { @wakes = @wakes.select(&:alive?) << thread }
89
+ thread
90
+ end
91
+
92
+ # Waits up to +timeout+ for the parent wakes this process started: a
93
+ # worker's teardown, since its process exits right after (a crash's
94
+ # ring comes just before it) and an exit kills a wake midway, before
95
+ # it spawned the parent's worker.
96
+ # @return [Integer] the wakes still running after it
97
+ def await_wakes(timeout = WAKE_JOIN_SECONDS)
98
+ threads = @wakes_mutex.synchronize { @wakes.tap { @wakes = [] } }
99
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
100
+ threads.each { |thread| thread.join([deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC), 0].max) }
101
+ left = threads.count(&:alive?)
102
+ Log.warn(:worker, "delegate_wakes_left", count: left) if left.positive?
103
+ left
82
104
  end
83
105
  end
84
106
 
@@ -93,7 +115,6 @@ module Samagotchi
93
115
  # failed: the next turn reads the same rings again.
94
116
  class ChildReports
95
117
  SOURCE = Steer::DELEGATE_REPORT
96
- CLIENT_PREFIX = Steer::CHILD_CLIENT_PREFIX
97
118
 
98
119
  # A report for the model: the text delegate_result would return
99
120
  # (DelegateWait.finish, its report variant), the cursor it moves the child to,
@@ -101,7 +122,7 @@ module Samagotchi
101
122
  # itself, with delegate_result, leaves it alone).
102
123
  Report = Data.define(:child_id, :text, :before, :after, :rings) do
103
124
  def line(mark: nil) = Steer::Line.new(text: text, source: ChildReports::SOURCE, mark: mark)
104
- def origin = { client_id: "#{ChildReports::CLIENT_PREFIX}#{child_id[0, 8]}" }
125
+ def origin = { client_id: "#{ClientId::CHILD_PREFIX}#{child_id[0, 8]}" }
105
126
  end
106
127
 
107
128
  def initialize(session_id:, state_dir:)
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Samagotchi
4
+ # Who sent a turn, a line or an answer: its client_id, as a turn's origin,
5
+ # an input file and the events carry it. The one table of the ids chi
6
+ # makes, and of which of them are a human's input (#human?). No requires,
7
+ # so any file can use it.
8
+ module ClientId
9
+ # A web tab (web:<random>, per tab); web:restart, the page's Restart.
10
+ WEB_PREFIX = "web:"
11
+ WEB_RESTART = "web:restart"
12
+ # An attached chi TUI: tui:<pid>.
13
+ TUI_PREFIX = "tui:"
14
+ # chi send's turns and commands.
15
+ CLI_SEND = "cli:send"
16
+ # chi answer's answers (a parent agent's, Guardrails::ParentApprovals).
17
+ CLI_ANSWER = "cli:answer"
18
+ # chi restart.
19
+ CLI_RESTART = "cli:restart"
20
+ # A delegating parent's task or follow-up: delegate:<parent8>.
21
+ DELEGATE_PREFIX = "delegate:"
22
+ # A delegate child's report (ChildReports): child:<child8>.
23
+ CHILD_PREFIX = "child:"
24
+ # A turn chi ran because an attached context source changed:
25
+ # context:<name> (Worker's context wake).
26
+ CONTEXT_PREFIX = "context:"
27
+ # chi's own turns; system:reminder, the turn queued when reminders are due.
28
+ SYSTEM_PREFIX = "system:"
29
+ REMINDER = "system:reminder"
30
+ # A plugin's message (Plugin::Sessions#send).
31
+ PLUGIN = "plugin"
32
+ # A relayed answer (RelayVerifier): relay:<parent8>.
33
+ RELAY_PREFIX = "relay:"
34
+
35
+ # Input a human typed: a web tab, a chi TUI, chi send, and nil (a
36
+ # worker's initial prompt; a web client may send none). An allowlist:
37
+ # delegates, plugins, reminders, context changes and any id chi doesn't
38
+ # know are not.
39
+ HUMAN_PREFIXES = [WEB_PREFIX, TUI_PREFIX].freeze
40
+ HUMAN_IDS = [CLI_SEND].freeze
41
+
42
+ module_function
43
+
44
+ # Whether +client_id+ (a turn's or a line's origin) is a human's input.
45
+ def human?(client_id)
46
+ return true if client_id.nil?
47
+
48
+ id = client_id.to_s
49
+ HUMAN_IDS.include?(id) || HUMAN_PREFIXES.any? { |prefix| id.start_with?(prefix) }
50
+ end
51
+ end
52
+ end
@@ -118,7 +118,8 @@ module Samagotchi
118
118
  if sub.seen != snapshot.revision
119
119
  args = { name: name, hint: hint, summary: snapshot.summary, changes: snapshot.serial - sub.seen_serial }
120
120
  found = delivery(name, text_note_id(name, snapshot), self.class.updated_text(**args), now, seen(sub, snapshot))
121
- return found unless snapshot.wake
121
+ # Any revision since the one the session last saw may have asked.
122
+ return found unless snapshot.wake_serial.to_i > sub.seen_serial
122
123
 
123
124
  return found.with(wake_note: found.note.merge(text: self.class.updated_text(**args, wake: true)))
124
125
  end
@@ -4,6 +4,7 @@ require "fileutils"
4
4
  require_relative "context_sources"
5
5
  require_relative "context_providers"
6
6
  require_relative "log"
7
+ require_relative "process_group"
7
8
 
8
9
  module Samagotchi
9
10
  # Runs an attached context source's command once and records the result
@@ -117,47 +118,26 @@ module Samagotchi
117
118
 
118
119
  out_r, out_w = IO.pipe
119
120
  err_r, err_w = IO.pipe
120
- pid = Process.spawn(env, "sh", "-c", cmd, chdir: cwd, pgroup: true, in: File::NULL, out: out_w, err: err_w)
121
+ pid = ProcessGroup.spawn(env, "sh", "-c", cmd, chdir: cwd, in: File::NULL, out: out_w, err: err_w)
121
122
  out_w.close
122
123
  err_w.close
123
- output = +""
124
- over = false
125
- out_reader = Thread.new do
126
- loop do
127
- chunk = out_r.readpartial(READ_CHUNK)
128
- if output.bytesize + chunk.bytesize > ContextSources::TEXT_MAX_BYTES
129
- over = true
130
- break
131
- end
132
- output << chunk
133
- end
134
- rescue IOError # EOFError too
135
- nil
136
- end
137
- stderr = +""
138
- err_reader = Thread.new do
139
- loop do
140
- stderr << err_r.readpartial(READ_CHUNK)
141
- stderr = stderr.byteslice(-STDERR_TAIL_BYTES, STDERR_TAIL_BYTES) if stderr.bytesize > STDERR_TAIL_BYTES
142
- end
143
- rescue IOError # EOFError too
144
- nil
145
- end
124
+ out_reader = ProcessGroup::PipeReader.new(out_r, cap: ContextSources::TEXT_MAX_BYTES, chunk: READ_CHUNK)
125
+ err_reader = ProcessGroup::PipeReader.new(err_r, cap: STDERR_TAIL_BYTES, keep: :tail, chunk: READ_CHUNK)
146
126
 
147
- error, status = wait(pid, timeout: timeout, over: -> { over }, cancelled: cancelled)
127
+ error, status = wait(pid, timeout: timeout, over: -> { out_reader.over? }, cancelled: cancelled)
148
128
  reaped = true
149
129
  # Whatever the command left running in its group goes with it.
150
- signal_group(pid, "KILL")
151
- [out_reader, err_reader].each { |thread| thread.join(KILL_GRACE_SECONDS) || thread.kill }
152
- stderr_text = stderr.dup.force_encoding(Encoding::UTF_8).scrub
130
+ ProcessGroup.signal(pid, "KILL")
131
+ [out_reader, err_reader].each { |reader| reader.wait(KILL_GRACE_SECONDS) || reader.kill }
132
+ stderr_text = err_reader.text(Encoding::UTF_8).scrub
153
133
  Log.debug(:context, "stderr", text: stderr_text[-1000..] || stderr_text) unless stderr_text.strip.empty?
154
134
  return Run.new(output: nil, error: nil, stderr: stderr_text, cancelled: true) if error == :cancelled
155
135
 
156
136
  # Checked again once the reader is done: a command that exits before
157
137
  # wait's next tick never gets an over from it.
158
- error = OVER_CAP if over
138
+ error = OVER_CAP if out_reader.over?
159
139
  error ||= exit_error(status, stderr_text)
160
- Run.new(output: output, error: error, stderr: stderr_text, cancelled: false)
140
+ Run.new(output: out_reader.text, error: error, stderr: stderr_text, cancelled: false)
161
141
  rescue SystemCallError => e
162
142
  Run.new(output: nil, error: "couldn't run it: #{e.message}", stderr: "", cancelled: false)
163
143
  ensure
@@ -188,33 +168,22 @@ module Samagotchi
188
168
 
189
169
  def abandon(pid, readers)
190
170
  stop_group(pid)
191
- readers.each { |thread| thread&.kill }
171
+ readers.each { |reader| reader&.kill }
192
172
  rescue StandardError => e
193
173
  Log.warn(:context, "stop_failed", pid: pid, error: e.class.name)
194
174
  end
195
175
 
196
176
  # TERM, then KILL after a grace; @return [Process::Status, nil]
197
177
  def stop_group(pid)
198
- signal_group(pid, "TERM")
199
- deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + KILL_GRACE_SECONDS
200
- until Process.clock_gettime(Process::CLOCK_MONOTONIC) > deadline
201
- _, status = Process.wait2(pid, Process::WNOHANG)
202
- return status if status
178
+ status = nil
179
+ reaped = -> { status = Process.wait2(pid, Process::WNOHANG)&.last }
180
+ return status if ProcessGroup.stop(pid, grace: KILL_GRACE_SECONDS, poll: 0.05, stopped: reaped)
203
181
 
204
- sleep(0.05)
205
- end
206
- signal_group(pid, "KILL")
207
182
  Process.wait2(pid).last
208
183
  rescue Errno::ECHILD
209
184
  nil
210
185
  end
211
186
 
212
- def signal_group(pid, signal)
213
- Process.kill(signal, -pid)
214
- rescue Errno::ESRCH, Errno::EPERM
215
- nil
216
- end
217
-
218
187
  def exit_error(status, stderr)
219
188
  return nil if status.nil? || status.success?
220
189
 
@@ -67,26 +67,35 @@ module Samagotchi
67
67
  # +serial+ counts the revisions (an absorb that missed some says how
68
68
  # many); +error+ is the last failure, +error_since+ when the failures
69
69
  # began (cleared by a success), so a session hears about each run of
70
- # failures once. +summary+, +wake+ and +hint+ come with the revision.
70
+ # failures once. +summary+, +wake+ and +hint+ come with the revision;
71
+ # +wake_serial+ is the serial of the newest revision that asked to wake
72
+ # (nil: none), so a change that doesn't ask, recorded before an absorb,
73
+ # doesn't hide a wake asked since the session's last one.
71
74
  # +fetched_at+ is the last success, +checked_at+ the last try (the
72
75
  # poller's clock: a failing source isn't run again every tick).
73
- Snapshot = Data.define(:text, :summary, :revision, :serial, :fetched_at, :checked_at, :wake, :hint, :error,
74
- :error_since) do
76
+ Snapshot = Data.define(:text, :summary, :revision, :serial, :fetched_at, :checked_at, :wake, :wake_serial, :hint,
77
+ :error, :error_since) do
75
78
  def self.empty
76
- new(text: nil, summary: nil, revision: nil, serial: 0, fetched_at: nil, checked_at: nil, wake: false, hint: nil,
77
- error: nil, error_since: nil)
79
+ new(text: nil, summary: nil, revision: nil, serial: 0, fetched_at: nil, checked_at: nil, wake: false,
80
+ wake_serial: nil, hint: nil, error: nil, error_since: nil)
78
81
  end
79
82
 
80
83
  def to_h
81
84
  { "text" => text, "summary" => summary, "revision" => revision, "serial" => serial,
82
- "fetched_at" => fetched_at, "checked_at" => checked_at, "wake" => (wake ? true : nil), "hint" => hint,
85
+ "fetched_at" => fetched_at, "checked_at" => checked_at, "wake" => (wake ? true : nil),
86
+ "wake_serial" => wake_serial, "hint" => hint,
83
87
  "error" => error, "error_since" => error_since }.compact
84
88
  end
85
89
 
90
+ # A snapshot 0.32.0 wrote has no wake_serial: its wake is the latest
91
+ # revision's.
86
92
  def self.from_h(data)
87
- new(text: data["text"], summary: data["summary"], revision: data["revision"], serial: data["serial"].to_i,
88
- fetched_at: data["fetched_at"], checked_at: data["checked_at"] || data["fetched_at"],
89
- wake: data["wake"] == true, hint: data["hint"], error: data["error"], error_since: data["error_since"])
93
+ serial = data["serial"].to_i
94
+ wake = data["wake"] == true
95
+ new(text: data["text"], summary: data["summary"], revision: data["revision"], serial: serial,
96
+ fetched_at: data["fetched_at"], checked_at: data["checked_at"] || data["fetched_at"], wake: wake,
97
+ wake_serial: data["wake_serial"]&.to_i || (serial if wake), hint: data["hint"], error: data["error"],
98
+ error_since: data["error_since"])
90
99
  end
91
100
 
92
101
  def text? = !text.nil?
@@ -230,7 +239,8 @@ module Samagotchi
230
239
  else
231
240
  Snapshot.new(text: fetched.text, revision: revision, serial: previous.serial + 1, fetched_at: stamp,
232
241
  checked_at: stamp, summary: fetched.summary || ContextSources.plain_summary(previous.text, fetched.text),
233
- wake: fetched.wake, hint: fetched.hint || previous.hint, error: nil, error_since: nil)
242
+ wake: fetched.wake, wake_serial: fetched.wake ? previous.serial + 1 : previous.wake_serial,
243
+ hint: fetched.hint || previous.hint, error: nil, error_since: nil)
234
244
  end
235
245
  write_snapshot(name, written)
236
246
  end
@@ -6,6 +6,7 @@ require "securerandom"
6
6
  require "time"
7
7
  require "yaml"
8
8
 
9
+ require_relative "client_id"
9
10
  require_relative "iteration_limit"
10
11
  require_relative "config"
11
12
  require_relative "context_note"
@@ -349,7 +350,8 @@ module Samagotchi
349
350
  # again with the message, no nudge, no retry spent. Too early, the
350
351
  # message waits (WaitingSteer) and cuts once the thinking passes it
351
352
  # (#recheck_waiting_steer, on each thinking chunk), unless a boundary
352
- # hands it to the model first. A plugin's message never cuts.
353
+ # hands it to the model first. A plugin's message never cuts, nor an
354
+ # unknown client's (automatic:<id>).
353
355
  # Call it after the message is queued, with the #input_epoch read
354
356
  # before queueing it. Outside any lock; a cut that lands just after the
355
357
  # generation ended is harmless (the loop re-asks).
@@ -811,6 +813,7 @@ module Samagotchi
811
813
  # #stop_idle's work (the scheduler's stop is idempotent), where the
812
814
  # callers haven't stopped it already.
813
815
  @idle_scheduler&.stop
816
+ @prompt_warmup.stop
814
817
  deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + join_timeout
815
818
  threads.each do |thread|
816
819
  next if thread == Thread.current
@@ -1183,17 +1186,18 @@ module Samagotchi
1183
1186
  private :hook_messages
1184
1187
 
1185
1188
  # How the turn ended, on the session (Session#last_turn): the caller's
1186
- # save puts it in the file the session hub watches.
1189
+ # save puts it in the file the session hub watches. Public for the
1190
+ # Worker's turn that failed before it began (no #end_turn ran).
1187
1191
  # @param limit [Integer, nil] the turn ran out of iterations at this
1188
1192
  # limit: "exhausted" => true, "limit" => N, so a wait with nobody to
1189
1193
  # answer the continue offer says so (ReplyWait, ParentReport "limit")
1190
1194
  # @param event [Hash] the turn's end event (#stop_facts)
1191
1195
  def record_last_turn(session, outcome, seconds, origin, event, limit: nil)
1192
1196
  client_id = origin.is_a?(Hash) ? origin[:client_id].to_s : ""
1193
- source = if client_id.start_with?("#{Tools::Delegate::CLIENT_PREFIX}:") then "delegate"
1194
- elsif client_id.start_with?(ChildReports::CLIENT_PREFIX) then "delegate_report"
1195
- elsif client_id.start_with?(Steer::CONTEXT_CLIENT_PREFIX) then "context"
1196
- elsif client_id == SessionManager::REMINDER_CLIENT_ID then "reminder"
1197
+ source = if client_id.start_with?(ClientId::DELEGATE_PREFIX) then "delegate"
1198
+ elsif client_id.start_with?(ClientId::CHILD_PREFIX) then "delegate_report"
1199
+ elsif client_id.start_with?(ClientId::CONTEXT_PREFIX) then "context"
1200
+ elsif client_id == ClientId::REMINDER then "reminder"
1197
1201
  else "client"
1198
1202
  end
1199
1203
  session.last_turn = { "outcome" => outcome, "ended_at" => Time.now.iso8601(3),
@@ -1201,7 +1205,6 @@ module Samagotchi
1201
1205
  session.last_turn.merge!("exhausted" => true, "limit" => limit) if limit
1202
1206
  session.last_turn.merge!(stop_facts(event))
1203
1207
  end
1204
- private :record_last_turn
1205
1208
 
1206
1209
  # Why a turn stopped, from its end event, for a parent agent's wait
1207
1210
  # (ReplyWait): a provider error's kind and whether a retry may help, a
@@ -30,8 +30,6 @@ module Samagotchi
30
30
  # rewrite would be "allow everything" from then on.
31
31
  module ParentApprovals
32
32
  KEY = "guardrails.parent_approvals"
33
- # The client id chi answer posts its answers with.
34
- CLIENT_ID = "cli:answer"
35
33
  # Set by chi's execute and task_create for the commands they run: a
36
34
  # chi started there answers as a parent (#parent_process?).
37
35
  PARENT_SESSION_ENV = "SAMAGOTCHI_PARENT_SESSION"
@@ -5,6 +5,7 @@ require "fileutils"
5
5
  require "tmpdir"
6
6
  require_relative "manifest"
7
7
  require_relative "placeholder"
8
+ require_relative "../context_providers"
8
9
  require_relative "../memory_paths"
9
10
  require_relative "../model_overlay"
10
11
 
@@ -152,6 +153,10 @@ module Samagotchi
152
153
  requires_chi = prov_data && prov_data[:requires_chi]
153
154
  needs = prov_data && prov_data[:needs]
154
155
 
156
+ # ── Scripts and context providers: the installed bundle's ──
157
+ scripts_to_copy = installed_scripts(prov, prov_data)
158
+ context_providers = prov_data ? ContextProviders.parse_list(prov_data[:context_providers]) : []
159
+
155
160
  # Determine trust_level for the built bundle
156
161
  build_trust_level = @trust_level
157
162
  if (build_trust_level.nil? || build_trust_level.empty?) && prov_data && prov_data[:trust_level]
@@ -185,6 +190,11 @@ module Samagotchi
185
190
 
186
191
  FileUtils.cp(plugin_src, File.join(staging, File.basename(plugin_src))) if plugin_src
187
192
 
193
+ unless scripts_to_copy.empty?
194
+ FileUtils.mkdir_p(File.join(staging, "scripts"))
195
+ scripts_to_copy.each_value { |src| FileUtils.cp(src, File.join(staging, "scripts", File.basename(src))) }
196
+ end
197
+
188
198
  Manifest.write(
189
199
  dir: staging,
190
200
  name: resolved_name,
@@ -196,7 +206,9 @@ module Samagotchi
196
206
  trust_level: build_trust_level,
197
207
  plugin: plugin_src && { file: File.basename(plugin_src), sha256: Digest::SHA256.hexdigest(File.binread(plugin_src)) },
198
208
  requires_chi: requires_chi,
199
- needs: needs
209
+ needs: needs,
210
+ scripts: scripts_to_copy.transform_values { |src| "sha256:#{Digest::SHA256.hexdigest(File.binread(src))}" },
211
+ context_providers: context_providers
200
212
  )
201
213
 
202
214
  case format
@@ -255,6 +267,15 @@ module Samagotchi
255
267
 
256
268
  private
257
269
 
270
+ # The installed bundle's scripts its record lists, file name => path
271
+ # in its scripts/ (a missing one is left out); {} without a record.
272
+ def installed_scripts(prov, prov_data)
273
+ return {} unless prov && prov_data && prov_data[:scripts].is_a?(Hash)
274
+
275
+ prov_data[:scripts].keys.map(&:to_s).sort.to_h { |file| [file, File.join(prov.scripts_dir, file)] }
276
+ .select { |_, path| File.file?(path) }
277
+ end
278
+
258
279
  # Without an allowlist, a memory another installed bundle owns (its
259
280
  # record lists it) isn't the user's to share: left out, one line
260
281
  # each. The bundle being built (same name) keeps its own.
@@ -124,7 +124,7 @@ module Samagotchi
124
124
 
125
125
  # Writes a fresh manifest.yml from computed checksums at `dir`.
126
126
  def self.write(dir:, name:, version:, files:, scope: nil, description: "", hooks: nil, trust_level: nil,
127
- plugin: nil, requires_chi: nil, needs: nil)
127
+ plugin: nil, requires_chi: nil, needs: nil, scripts: {}, context_providers: [])
128
128
  FileUtils.mkdir_p(dir)
129
129
  manifest = {
130
130
  "name" => name,
@@ -149,6 +149,8 @@ module Samagotchi
149
149
  manifest["requires_chi"] = requires_chi.to_s if requires_chi && !requires_chi.to_s.empty?
150
150
  needs = parse_needs(needs)
151
151
  manifest["needs"] = needs.map { |n| n.transform_keys(&:to_s).compact } unless needs.empty?
152
+ manifest["scripts"] = scripts.transform_keys(&:to_s) unless scripts.empty?
153
+ manifest["context_providers"] = context_providers.map(&:to_h) unless context_providers.empty?
152
154
  File.write(File.join(dir, "manifest.yml"), YAML.dump(manifest))
153
155
  end
154
156