samagotchi 0.31.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 (88) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +70 -1
  3. data/README.md +13 -0
  4. data/bin/chi +3 -0
  5. data/docs/cli.md +1 -0
  6. data/docs/configuration.md +3 -1
  7. data/docs/context.md +220 -0
  8. data/docs/guardrails.md +12 -4
  9. data/docs/hooks.md +7 -3
  10. data/docs/plugins.md +79 -2
  11. data/docs/releasing.md +3 -2
  12. data/lib/samagotchi/answer_command.rb +2 -1
  13. data/lib/samagotchi/archive_store.rb +1 -15
  14. data/lib/samagotchi/bootstrap_command.rb +4 -1
  15. data/lib/samagotchi/bridge.rb +8 -3
  16. data/lib/samagotchi/bundles/dev/manifest.yml +3 -2
  17. data/lib/samagotchi/bundles/github-pr/manifest.yml +22 -0
  18. data/lib/samagotchi/bundles/github-pr/plugin.rb +65 -0
  19. data/lib/samagotchi/bundles/github-pr/scripts/pr_context.rb +199 -0
  20. data/lib/samagotchi/bundles/guardrails/guardrails/rules.yml +15 -2
  21. data/lib/samagotchi/bundles/guardrails/manifest.yml +2 -2
  22. data/lib/samagotchi/bundles/loop-guard/manifest.yml +2 -2
  23. data/lib/samagotchi/bundles/loop-guard/plugin.rb +1 -1
  24. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  25. data/lib/samagotchi/bundles/system/self_map.md +6 -0
  26. data/lib/samagotchi/child_reports.rb +31 -12
  27. data/lib/samagotchi/client_id.rb +52 -0
  28. data/lib/samagotchi/config.rb +6 -0
  29. data/lib/samagotchi/context_absorber.rb +180 -0
  30. data/lib/samagotchi/context_command.rb +473 -0
  31. data/lib/samagotchi/context_fetch.rb +195 -0
  32. data/lib/samagotchi/context_note.rb +11 -5
  33. data/lib/samagotchi/context_poller.rb +100 -0
  34. data/lib/samagotchi/context_providers.rb +159 -0
  35. data/lib/samagotchi/context_sources.rb +462 -0
  36. data/lib/samagotchi/engine.rb +19 -11
  37. data/lib/samagotchi/guardrail_wiring.rb +3 -1
  38. data/lib/samagotchi/guardrails/parent_approvals.rb +9 -9
  39. data/lib/samagotchi/guardrails/protected_paths.rb +8 -5
  40. data/lib/samagotchi/guardrails/shell_lex.rb +0 -9
  41. data/lib/samagotchi/guardrails/verdict.rb +21 -3
  42. data/lib/samagotchi/log_line.rb +1 -1
  43. data/lib/samagotchi/memory_bundle/builder.rb +22 -1
  44. data/lib/samagotchi/memory_bundle/installer.rb +43 -1
  45. data/lib/samagotchi/memory_bundle/manifest.rb +42 -4
  46. data/lib/samagotchi/memory_bundle/provenance.rb +17 -1
  47. data/lib/samagotchi/plugin/attached_context.rb +100 -0
  48. data/lib/samagotchi/plugin/context.rb +22 -0
  49. data/lib/samagotchi/plugin/sessions.rb +2 -1
  50. data/lib/samagotchi/process_group.rb +160 -0
  51. data/lib/samagotchi/prompt_warmup.rb +20 -1
  52. data/lib/samagotchi/question_desk.rb +2 -4
  53. data/lib/samagotchi/relay_verifier.rb +2 -4
  54. data/lib/samagotchi/reply_wait.rb +9 -4
  55. data/lib/samagotchi/send_command.rb +2 -1
  56. data/lib/samagotchi/session.rb +7 -5
  57. data/lib/samagotchi/session_commands.rb +18 -1
  58. data/lib/samagotchi/session_manager.rb +7 -6
  59. data/lib/samagotchi/session_retention.rb +3 -0
  60. data/lib/samagotchi/steer.rb +36 -19
  61. data/lib/samagotchi/system_prompt.rb +1 -1
  62. data/lib/samagotchi/terminal_ui/attached_loop.rb +12 -7
  63. data/lib/samagotchi/terminal_ui/formatting.rb +28 -8
  64. data/lib/samagotchi/terminal_ui.rb +2 -4
  65. data/lib/samagotchi/tool_activity.rb +8 -0
  66. data/lib/samagotchi/tool_declarations.rb +21 -0
  67. data/lib/samagotchi/tools/builtin_calls.rb +1 -2
  68. data/lib/samagotchi/tools/builtins.rb +5 -0
  69. data/lib/samagotchi/tools/context_read.rb +163 -0
  70. data/lib/samagotchi/tools/delegate.rb +2 -2
  71. data/lib/samagotchi/tools/execute.rb +10 -81
  72. data/lib/samagotchi/tools/task_runtime.rb +25 -38
  73. data/lib/samagotchi/version.rb +1 -1
  74. data/lib/samagotchi/web/app.rb +111 -2
  75. data/lib/samagotchi/web/public/app.js +57 -28
  76. data/lib/samagotchi/web/public/composer_size.js +7 -6
  77. data/lib/samagotchi/web/public/context_chips.js +285 -0
  78. data/lib/samagotchi/web/public/data.js +24 -0
  79. data/lib/samagotchi/web/public/index.html +49 -3
  80. data/lib/samagotchi/web/public/notify.js +5 -3
  81. data/lib/samagotchi/web/public/question_card.js +3 -4
  82. data/lib/samagotchi/web/public/shown_texts.js +13 -0
  83. data/lib/samagotchi/web/public/stable_html.js +18 -0
  84. data/lib/samagotchi/web/public/timing.js +7 -3
  85. data/lib/samagotchi/web/public/turn_events.js +16 -3
  86. data/lib/samagotchi/web/public/turn_view.js +5 -2
  87. data/lib/samagotchi/worker.rb +154 -6
  88. metadata +18 -1
@@ -18,6 +18,7 @@ require_relative "cancel_reminder"
18
18
  require_relative "list_reminders"
19
19
  require_relative "list_sessions"
20
20
  require_relative "send_note"
21
+ require_relative "context_read"
21
22
  require_relative "delegate"
22
23
  require_relative "delegate_result"
23
24
  require_relative "ask_user_question"
@@ -50,6 +51,7 @@ module Samagotchi
50
51
  ListReminders,
51
52
  ListSessions,
52
53
  SendNote,
54
+ ContextRead,
53
55
  Delegate,
54
56
  DelegateResult,
55
57
  AskUserQuestion
@@ -92,6 +94,9 @@ module Samagotchi
92
94
  ListReminders::NAME => ->(call, kctx) { ListReminders.call(call[:content], reminder_store: kctx.reminder_store) },
93
95
  ListSessions::NAME => ->(call, kctx) { ListSessions.call(call[:content], peers: kctx.peers, cwd: call[:cwd]) },
94
96
  SendNote::NAME => ->(call, kctx) { SendNote.call(call[:content], session: call[:session], peers: kctx.peers) },
97
+ ContextRead::NAME => lambda do |call, kctx|
98
+ ContextRead.call(call[:content], offset: call[:offset], limit: call[:limit], peers: kctx.peers)
99
+ end,
95
100
  Delegate::NAME => lambda do |call, kctx|
96
101
  Delegate.call(call[:content], model: call[:model], session: call[:session], wait: call[:wait],
97
102
  timeout: call[:timeout], peers: kctx.peers)
@@ -0,0 +1,163 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+ require_relative "../config"
5
+ require_relative "../context_sources"
6
+ require_relative "../project_scope"
7
+
8
+ module Samagotchi
9
+ module Tools
10
+ # Read the session's attached context (ContextSources): without a name
11
+ # the list, with one its header and text, paged by lines when it is
12
+ # longer than max_tool_output_chars. It reads snapshots only (never
13
+ # runs a source's command) and records what was read, so the web can
14
+ # show a change the agent hasn't read.
15
+ class ContextRead
16
+ NAME = "context_read"
17
+ DEFAULT_MAX_CHARS = 10_000
18
+ # Room for the header and the paging line inside the cap.
19
+ HEADER_ROOM = 1_000
20
+
21
+ def self.name = NAME
22
+
23
+ # @param content [String] the source's name; blank for the list
24
+ # @param offset [Integer, String, nil] the first line (1-based)
25
+ # @param limit [Integer, String, nil] how many lines at most
26
+ # @param peers [Peers, nil] the session (id, cwd, state dir)
27
+ def self.call(content, offset: nil, limit: nil, peers: nil, max_chars: nil, now: Time.now)
28
+ return "Error: this session's id is not known here" unless peers&.session_id
29
+
30
+ new(peers, max_chars: max_chars || config_cap, now: now).run(content.to_s.strip, offset: offset, limit: limit)
31
+ rescue ContextSources::Invalid => e
32
+ "Error: #{e.message}"
33
+ end
34
+
35
+ def self.config_cap
36
+ value = Config.get("max_tool_output_chars").to_i
37
+ value.positive? ? value : DEFAULT_MAX_CHARS
38
+ end
39
+
40
+ def initialize(peers, max_chars:, now:)
41
+ @state_dir = peers.state_dir
42
+ @own = ContextSources.session_location(peers.session_id, state_dir: @state_dir)
43
+ # The Engine's PeerView knows the session's project; a plain Peers
44
+ # (specs, a bare kernel) has only its folder.
45
+ root = peers.respond_to?(:project_root) ? peers.project_root : ProjectScope.root_for(peers.cwd)
46
+ @attached = ContextSources.attached(peers.session_id, project_root: root, state_dir: @state_dir)
47
+ @max_chars = [max_chars.to_i, HEADER_ROOM * 2].max
48
+ @now = now
49
+ end
50
+
51
+ def run(name, offset:, limit:)
52
+ return list if name.empty?
53
+
54
+ ContextSources.check_name!(name)
55
+ attached = @attached.find { |a| a.name == name }
56
+ return "Error: no attached source #{name}; context_read without a name lists them" unless attached
57
+
58
+ read(attached, offset: offset, limit: limit)
59
+ end
60
+
61
+ private
62
+
63
+ # Muted sources are left out: this session ignores them.
64
+ def list
65
+ shown = @attached.reject { |a| @own.muted?(a.name) }
66
+ return "No attached context in this session." if shown.empty?
67
+
68
+ subs = @own.subscriptions
69
+ lines = shown.map do |a|
70
+ snapshot = a.snapshot
71
+ parts = ["- #{a.name}"]
72
+ parts << "why: #{a.source.why}" if a.source.why
73
+ hint = snapshot.hint || a.source.hint
74
+ parts << "hint: #{hint}" if hint
75
+ if snapshot.text?
76
+ parts << "fetched #{age(snapshot.fetched_at)}"
77
+ parts << "changed since you last read it: #{subs[a.name]&.read == snapshot.revision ? "no" : "yes"}"
78
+ else
79
+ parts << "no text yet"
80
+ end
81
+ parts << "last refresh failed: #{snapshot.error}" if snapshot.error
82
+ parts.join("; ")
83
+ end
84
+ "Attached context (#{shown.size}):\n#{lines.join("\n")}"
85
+ end
86
+
87
+ def read(attached, offset:, limit:)
88
+ snapshot = attached.snapshot
89
+ header = header_lines(attached, snapshot)
90
+ unless snapshot.text?
91
+ return "#{header.join("\n")}\nNo text yet#{": the last refresh failed" if snapshot.error}."
92
+ end
93
+
94
+ lines = snapshot.text.lines
95
+ first = positive(offset) || 1
96
+ return "#{header.join("\n")}\nThe text has #{lines.size} lines; offset #{first} is past its end." if first > lines.size
97
+
98
+ page = lines[(first - 1)..] || []
99
+ page = page.first(positive(limit)) if positive(limit)
100
+ page = fit(page)
101
+ last = first + page.size - 1
102
+ @own.update_subscription(attached.name) { |sub| sub.with(read: snapshot.revision) }
103
+
104
+ if first > 1 || last < lines.size
105
+ more = last < lines.size ? " Pass offset: #{last + 1} for more." : ""
106
+ header << "Lines #{first}-#{last} of #{lines.size}.#{more}"
107
+ end
108
+ "#{header.join("\n")}\n---\n#{page.join}"
109
+ end
110
+
111
+ def header_lines(attached, snapshot)
112
+ source = attached.source
113
+ hint = snapshot.hint || source.hint
114
+ header = ["#{source.name}#{" (#{hint})" if hint}"]
115
+ header << "Why: #{source.why}" if source.why
116
+ header << "Fetched: #{clock(snapshot.fetched_at)} (#{age(snapshot.fetched_at)})" if snapshot.fetched_at
117
+ header << "Summary: #{snapshot.summary}" if snapshot.summary
118
+ header << "Last refresh failed: #{snapshot.error}" if snapshot.error
119
+ header
120
+ end
121
+
122
+ # The lines that fit in the cap (at least one, cut when that one is
123
+ # longer than the cap).
124
+ def fit(lines)
125
+ budget = @max_chars - HEADER_ROOM
126
+ taken = []
127
+ used = 0
128
+ lines.each do |line|
129
+ break if used + line.length > budget && taken.any?
130
+
131
+ taken << (line.length > budget ? "#{line[0, budget]}…\n" : line)
132
+ used += line.length
133
+ end
134
+ taken
135
+ end
136
+
137
+ def positive(value)
138
+ number = Integer(value.to_s, 10, exception: false)
139
+ number&.positive? ? number : nil
140
+ end
141
+
142
+ def clock(iso)
143
+ Time.iso8601(iso).localtime.strftime("%Y-%m-%d %H:%M")
144
+ rescue ArgumentError
145
+ iso.to_s
146
+ end
147
+
148
+ def age(iso)
149
+ return "never" unless iso
150
+
151
+ seconds = (@now - Time.iso8601(iso)).to_i
152
+ if seconds < 60 then "just now"
153
+ elsif seconds < 3600 then "#{seconds / 60}m ago"
154
+ elsif seconds < 86_400 then "#{seconds / 3600}h ago"
155
+ else
156
+ "#{seconds / 86_400}d ago"
157
+ end
158
+ rescue ArgumentError
159
+ "at an unknown time"
160
+ end
161
+ end
162
+ end
163
+ end
@@ -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 "../config"
5
6
  require_relative "../model_profile"
@@ -21,7 +22,6 @@ module Samagotchi
21
22
  # reply comes back (DelegateWait), never its trace.
22
23
  class Delegate
23
24
  NAME = "delegate"
24
- CLIENT_PREFIX = "delegate"
25
25
  MAX_CHILDREN_KEY = "session.max_children"
26
26
  MAX_CHILDREN_DEFAULT = 4
27
27
  # A child spawned this many seconds ago counts as running even before
@@ -113,7 +113,7 @@ module Samagotchi
113
113
 
114
114
  # Only a reply after this delivery counts as the follow-up's.
115
115
  DelegateWait.mark_seen(parent.id, child, state_dir: state_dir)
116
- delivered = SessionManager.deliver_turn(id, prompt: task, client_id: "#{CLIENT_PREFIX}:#{parent.id[0, 8]}",
116
+ delivered = SessionManager.deliver_turn(id, prompt: task, client_id: "#{ClientId::DELEGATE_PREFIX}#{parent.id[0, 8]}",
117
117
  state_dir: state_dir)
118
118
  case delivered[:status]
119
119
  when :accepted then id
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "open3"
4
+ require_relative "../process_group"
4
5
  require_relative "output_guardrails"
5
6
  require_relative "shell"
6
7
 
@@ -95,7 +96,7 @@ module Samagotchi
95
96
 
96
97
  Open3.popen3(env || {}, *Shell.argv(command), chdir: cwd, pgroup: true) do |stdin, stdout, stderr, wait_thr|
97
98
  stdin.close
98
- readers = [OutputReader.new(stdout), OutputReader.new(stderr)]
99
+ readers = [stdout, stderr].map { |io| ProcessGroup::PipeReader.new(io, chunk: READ_CHUNK_BYTES) }
99
100
 
100
101
  begin
101
102
  until wait_thr.join(WAIT_SLICE_SEC)
@@ -132,7 +133,7 @@ module Samagotchi
132
133
  stopped = stop_process_group(wait_thr.pid, readers)
133
134
  held_open = (stopped ? :stopped : :detached) if outcome == :grace && !stopping
134
135
  end
135
- readers.each(&:finish)
136
+ readers.each { |reader| reader.finish(poll: STOP_POLL_INTERVAL_SEC, grace: STOP_GRACE_SEC) }
136
137
  stdout_text, stderr_text = readers.map(&:text)
137
138
  end
138
139
  end
@@ -162,95 +163,23 @@ module Samagotchi
162
163
  # has members holding the output: TERM the group, then KILL it. A process
163
164
  # that left the group (setsid) is out of reach; the pipes get closed.
164
165
  # True once the output was released, false if something still holds it.
166
+ # Only the group: the leader's pid is reaped, so it may name another process.
165
167
  def self.stop_process_group(pgid, readers)
166
- signal_group(pgid, "TERM")
167
- return true if await_readers(readers, until_time: monotonic_time + STOP_GRACE_SEC, deadline: nil,
168
- cancelled: -> { false }) == :done
168
+ released = -> { readers.all?(&:done?) }
169
+ wait = ->(seconds) { readers.find { |r| !r.done? }&.wait(seconds) }
170
+ return true if ProcessGroup.stop(pgid, grace: STOP_GRACE_SEC, poll: STOP_POLL_INTERVAL_SEC, stopped: released, wait: wait)
169
171
 
170
- signal_group(pgid, "KILL")
171
172
  await_readers(readers, until_time: monotonic_time + STOP_GRACE_SEC, deadline: nil, cancelled: -> { false }) == :done
172
173
  end
173
174
  private_class_method :stop_process_group
174
175
 
175
- # Only the group: the leader's pid is reaped, so it may name another process.
176
- def self.signal_group(pgid, signal)
177
- Process.kill(signal, -pgid)
178
- rescue Errno::ESRCH, Errno::EPERM
179
- nil
180
- end
181
- private_class_method :signal_group
182
-
183
- # Reads a pipe in chunks on its own thread, so the output read so far
184
- # survives the pipe being closed under it.
185
- class OutputReader
186
- def initialize(io)
187
- @io = io
188
- @buffer = String.new(encoding: Encoding::BINARY)
189
- @thread = Thread.new do
190
- Thread.current.report_on_exception = false
191
- loop { @buffer << io.readpartial(READ_CHUNK_BYTES) }
192
- rescue IOError # EOFError is one
193
- nil
194
- end
195
- end
196
-
197
- def done? = !@thread.alive?
198
-
199
- def wait(seconds) = @thread.join(seconds)
200
-
201
- # Closes the pipe (a reader still blocked gets IOError) and ends the
202
- # thread; the text is safe to read afterwards.
203
- def finish
204
- @thread.join(STOP_POLL_INTERVAL_SEC) unless done?
205
- close_quietly
206
- @thread.kill unless @thread.join(STOP_GRACE_SEC)
207
- end
208
-
209
- def text
210
- @buffer.dup.force_encoding(@io.external_encoding || Encoding.default_external)
211
- end
212
-
213
- private
214
-
215
- def close_quietly
216
- @io.close unless @io.closed?
217
- rescue IOError
218
- nil
219
- end
220
- end
221
- private_constant :OutputReader
222
-
176
+ # The shell still runs: TERM its group (or the shell alone, if it left
177
+ # the group), then KILL it once the grace is over.
223
178
  def self.terminate_process_tree(pid)
224
- signal_process(pid, "TERM")
225
-
226
- deadline = monotonic_time + STOP_GRACE_SEC
227
- sleep(STOP_POLL_INTERVAL_SEC) while process_alive?(pid) && monotonic_time < deadline
228
-
229
- signal_process(pid, "KILL") if process_alive?(pid)
179
+ ProcessGroup.stop(pid, grace: STOP_GRACE_SEC, poll: STOP_POLL_INTERVAL_SEC, leader: true)
230
180
  end
231
181
  private_class_method :terminate_process_tree
232
182
 
233
- def self.signal_process(pid, signal)
234
- Process.kill(signal, -pid)
235
- rescue Errno::ESRCH, Errno::EPERM
236
- begin
237
- Process.kill(signal, pid)
238
- rescue Errno::ESRCH, Errno::EPERM
239
- nil
240
- end
241
- end
242
- private_class_method :signal_process
243
-
244
- def self.process_alive?(pid)
245
- Process.kill(0, pid)
246
- true
247
- rescue Errno::ESRCH
248
- false
249
- rescue Errno::EPERM
250
- true
251
- end
252
- private_class_method :process_alive?
253
-
254
183
  def self.monotonic_time
255
184
  Process.clock_gettime(Process::CLOCK_MONOTONIC)
256
185
  end
@@ -6,6 +6,7 @@ require "securerandom"
6
6
  require "shellwords"
7
7
  require "time"
8
8
  require_relative "../atomic_file"
9
+ require_relative "../process_group"
9
10
  require_relative "shell"
10
11
 
11
12
  module Samagotchi
@@ -24,6 +25,9 @@ module Samagotchi
24
25
  MARKER_ENV_KEYS = %w[SAMAGOTCHI_PARENT_SESSION SAMAGOTCHI_SESSION_MODEL].freeze
25
26
  # task_wait and task_get add it to a task the user stopped (the web's
26
27
  # stop-task button), so the model doesn't rerun the job.
28
+ # A record whose pid isn't a process group chi started (missing, 0, 1,
29
+ # not a number, or a pid another process reuses): nothing is signalled.
30
+ NOT_CHIS_PROCESS = "the task's process is gone or isn't the one chi started"
27
31
  USER_STOP_NOTE = "note: the user stopped this task; don't restart it unless they ask."
28
32
 
29
33
  module_function
@@ -54,13 +58,12 @@ module Samagotchi
54
58
 
55
59
  wrapped_command = wrapped_shell_command(normalized_command, exit_code_path)
56
60
  # Non-login, the shell Execute uses too (Shell).
57
- pid = Process.spawn(
61
+ pid = ProcessGroup.spawn(
58
62
  spawn_env,
59
63
  *Shell.argv(wrapped_command),
60
64
  chdir: resolved_cwd,
61
65
  out: output_io,
62
- err: output_io,
63
- pgroup: true
66
+ err: output_io
64
67
  )
65
68
  Process.detach(pid)
66
69
 
@@ -185,26 +188,15 @@ module Samagotchi
185
188
  refreshed = refresh_record(record)
186
189
  return [refreshed, nil] unless refreshed["status"] == "running"
187
190
 
191
+ pid = refreshed["pid"]
192
+ # Checked again just before the signal: never a group chi didn't start.
193
+ return [mark_not_chis(refreshed), nil] unless ProcessGroup.leader?(pid)
194
+
188
195
  refreshed["stop_requested_by"] = by.to_s
189
196
  write_record(refreshed)
190
- pid = refreshed["pid"].to_i
191
-
192
- begin
193
- Process.kill("TERM", -pid)
194
- rescue Errno::ESRCH
195
- # Process already exited; status refresh below handles final state.
196
- end
197
197
 
198
- deadline = monotonic_time + STOP_GRACE_SEC
199
- sleep(STOP_POLL_INTERVAL_SEC) while process_alive?(pid) && monotonic_time < deadline
200
-
201
- if process_alive?(pid)
202
- begin
203
- Process.kill("KILL", -pid)
204
- rescue Errno::ESRCH
205
- # Process exited between checks.
206
- end
207
- end
198
+ # A group that isn't ours (EPERM) is an error, not a stop.
199
+ ProcessGroup.stop(pid, grace: STOP_GRACE_SEC, poll: STOP_POLL_INTERVAL_SEC, quiet: [Errno::ESRCH])
208
200
 
209
201
  updated = mark_stopped(refresh_record(refreshed))
210
202
  write_record(updated)
@@ -217,10 +209,18 @@ module Samagotchi
217
209
  def refresh_record(record)
218
210
  return record unless record["status"] == "running"
219
211
 
220
- pid = record["pid"].to_i
221
- return mark_finished_without_exit_code(record) unless process_alive?(pid)
212
+ pid = record["pid"]
213
+ return record if ProcessGroup.leader?(pid)
214
+ # Gone: its exit code says how it ended.
215
+ return mark_finished_without_exit_code(record) if ProcessGroup.signalable?(pid) && !ProcessGroup.alive?(pid)
222
216
 
223
- record
217
+ mark_not_chis(record)
218
+ end
219
+
220
+ # A running record whose pid isn't a group chi started (NOT_CHIS_PROCESS)
221
+ # ends as failed, unless its exit code or a stop on disk says more.
222
+ def mark_not_chis(record)
223
+ mark_finished_without_exit_code(record, missing_reason: NOT_CHIS_PROCESS)
224
224
  end
225
225
 
226
226
  # A session id (a uuid) is never a task's: task ids are a timestamp
@@ -338,16 +338,7 @@ module Samagotchi
338
338
  filtered.join(" ")
339
339
  end
340
340
 
341
- def process_alive?(pid)
342
- Process.kill(0, pid)
343
- true
344
- rescue Errno::ESRCH
345
- false
346
- rescue Errno::EPERM
347
- true
348
- end
349
-
350
- def mark_finished_without_exit_code(record)
341
+ def mark_finished_without_exit_code(record, missing_reason: "process_ended_without_exit_code")
351
342
  record = current_record(record)
352
343
  return record unless record["status"] == "running"
353
344
 
@@ -359,7 +350,7 @@ module Samagotchi
359
350
  elsif exit_code.nil?
360
351
  record["status"] = "failed"
361
352
  record["exit_code"] = nil
362
- record["stop_reason"] ||= "process_ended_without_exit_code"
353
+ record["stop_reason"] ||= missing_reason
363
354
  elsif exit_code.zero?
364
355
  record["status"] = "completed"
365
356
  record["exit_code"] = 0
@@ -401,10 +392,6 @@ module Samagotchi
401
392
  def timestamp
402
393
  Time.now.utc.iso8601
403
394
  end
404
-
405
- def monotonic_time
406
- Process.clock_gettime(Process::CLOCK_MONOTONIC)
407
- end
408
395
  end
409
396
  end
410
397
  end
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Samagotchi
4
- VERSION = "0.31.0"
4
+ VERSION = "0.33.0"
5
5
  # Sent as the User-Agent of every HTTP request chi makes.
6
6
  USER_AGENT = "chi/#{VERSION}"
7
7
  end
@@ -9,6 +9,7 @@ require "uri"
9
9
  require "rack"
10
10
  require "rack/request"
11
11
 
12
+ require_relative "../client_id"
12
13
  require_relative "../answer_tail"
13
14
  require_relative "../bridge_client"
14
15
  require_relative "../bridge/bounded_queue"
@@ -29,6 +30,8 @@ require_relative "../config"
29
30
  require_relative "../output_formatter"
30
31
  require_relative "../image_store"
31
32
  require_relative "../context_note"
33
+ require_relative "../context_sources"
34
+ require_relative "../context_providers"
32
35
  require_relative "../recap_store"
33
36
  require_relative "markdown_renderer"
34
37
  require_relative "message_parts"
@@ -150,6 +153,10 @@ module Samagotchi
150
153
  ["POST", %r{\A/api/sessions/([^/]+)/command\z}, :handle_command],
151
154
  ["POST", %r{\A/api/sessions/([^/]+)/images\z}, :handle_image_upload],
152
155
  ["GET", %r{\A/api/sessions/([^/]+)/images/([^/]+)\z}, :handle_image],
156
+ ["GET", %r{\A/api/sessions/([^/]+)/context\z}, :handle_context_list],
157
+ ["GET", %r{\A/api/sessions/([^/]+)/context/([^/]+)\z}, :handle_context_show],
158
+ ["POST", %r{\A/api/sessions/([^/]+)/context\z}, :handle_context_add],
159
+ ["DELETE", %r{\A/api/sessions/([^/]+)/context/([^/]+)\z}, :handle_context_delete],
153
160
  ["GET", %r{\A/api/sessions/([^/]+)\z}, :handle_show],
154
161
  ["DELETE", %r{\A/api/sessions/([^/]+)\z}, :handle_delete]
155
162
  ].freeze
@@ -1063,7 +1070,7 @@ module Samagotchi
1063
1070
  # {error: "held" | "not_running" | "unsupported" | "failed", reason, detail} in words; 409
1064
1071
  # owned_by_tui; 404.
1065
1072
  def handle_restart(_req, id)
1066
- result = @manager.restart_session(id, state_dir: @state_dir, client_id: "web:restart")
1073
+ result = @manager.restart_session(id, state_dir: @state_dir, client_id: ClientId::WEB_RESTART)
1067
1074
  @hub&.touch(id)
1068
1075
  json_response(200, { status: "restarted", session_id: id, from_version: result.from_version,
1069
1076
  version: result.version })
@@ -1117,6 +1124,101 @@ module Samagotchi
1117
1124
  error_response(404, "not_found", e.message)
1118
1125
  end
1119
1126
 
1127
+ # ── Attached context (ContextSources): the session bar's chips ───────
1128
+ # A source's command isn't shown (the page may be on a phone over the
1129
+ # LAN), and the web adds a URL only, through an installed bundle's
1130
+ # provider (ContextProviders): never a command.
1131
+
1132
+ # GET /api/sessions/:id/context: the sources this session sees, muted
1133
+ # ones marked, with what the chips show.
1134
+ def handle_context_list(_req, id)
1135
+ session = @session_class.load(id, state_dir: default_state_dir)
1136
+ own = ContextSources.session_location(id, state_dir: default_state_dir)
1137
+ subs = own.subscriptions
1138
+ rows = attached_context(session).map { |attached| context_row(attached, own, subs) }
1139
+ json_response(200, { session_id: id, sources: rows, can_add_url: ContextProviders.any? })
1140
+ rescue ArgumentError => e
1141
+ error_response(404, "not_found", e.message)
1142
+ end
1143
+
1144
+ # GET /api/sessions/:id/context/:name: one source with its text.
1145
+ def handle_context_show(_req, id, name)
1146
+ session = @session_class.load(id, state_dir: default_state_dir)
1147
+ attached = find_context(session, name) or return error_response(404, "not_found", "no source #{name[0, 40]}")
1148
+ own = ContextSources.session_location(id, state_dir: default_state_dir)
1149
+ json_response(200, context_row(attached, own, own.subscriptions).merge(text: attached.snapshot.text))
1150
+ rescue ArgumentError => e
1151
+ error_response(404, "not_found", e.message)
1152
+ end
1153
+
1154
+ # POST /api/sessions/:id/context {url, why}: the source an installed
1155
+ # bundle's provider makes of +url+, attached to this session.
1156
+ def handle_context_add(req, id)
1157
+ session = @session_class.load(id, state_dir: default_state_dir)
1158
+ body = parse_json(request_body(req))
1159
+ return error_response(400, "invalid_json", "give {url, why}") unless body.is_a?(Hash) && body["url"].is_a?(String)
1160
+
1161
+ url = body["url"].strip
1162
+ return error_response(422, "not_a_url", "give an http(s) URL") unless url.match?(%r{\Ahttps?://\S+\z})
1163
+
1164
+ resolved = ContextProviders.resolve(url)
1165
+ return error_response(422, "no_provider", "no installed bundle resolves #{url[0, 200]}") unless resolved
1166
+
1167
+ own = ContextSources.session_location(session.id, state_dir: default_state_dir)
1168
+ own.add(web_source(resolved, body["why"], own.scope))
1169
+ json_response(201, { status: "attached", name: resolved.name })
1170
+ rescue ContextProviders::Invalid => e
1171
+ error_response(422, "no_provider", e.message)
1172
+ rescue ContextSources::Invalid => e
1173
+ error_response(409, "exists", e.message)
1174
+ rescue ArgumentError => e
1175
+ error_response(404, "not_found", e.message)
1176
+ end
1177
+
1178
+ def web_source(resolved, why, scope)
1179
+ why = ContextSources.one_line(why.is_a?(String) ? why : nil, ContextSources::LINE_MAX_CHARS) || resolved.why
1180
+ ContextSources::Source.new(name: resolved.name, cmd: resolved.cmd, every_seconds: resolved.every_seconds, why: why,
1181
+ hint: resolved.hint, scope: scope, added_by: "web", created_at: Time.now.utc.iso8601,
1182
+ provider: resolved.bundle)
1183
+ end
1184
+
1185
+ # DELETE /api/sessions/:id/context/:name: the session's own source is
1186
+ # detached; a project's is muted for this session only.
1187
+ def handle_context_delete(_req, id, name)
1188
+ session = @session_class.load(id, state_dir: default_state_dir)
1189
+ attached = find_context(session, name) or return error_response(404, "not_found", "no source #{name[0, 40]}")
1190
+ if attached.location.session?
1191
+ attached.location.remove(attached.name)
1192
+ json_response(200, { status: "detached", name: attached.name })
1193
+ else
1194
+ ContextSources.session_location(id, state_dir: default_state_dir).mute(attached.name)
1195
+ json_response(200, { status: "muted", name: attached.name })
1196
+ end
1197
+ rescue ArgumentError => e
1198
+ error_response(404, "not_found", e.message)
1199
+ end
1200
+
1201
+ def attached_context(session)
1202
+ ContextSources.attached(session.id, project_root: session.project_root, state_dir: default_state_dir)
1203
+ end
1204
+
1205
+ # @return [ContextSources::Attached, nil]
1206
+ def find_context(session, name)
1207
+ ContextSources.check_name!(name)
1208
+ attached_context(session).find { |attached| attached.name == name }
1209
+ rescue ContextSources::Invalid
1210
+ nil
1211
+ end
1212
+
1213
+ def context_row(attached, own, subs)
1214
+ source = attached.source
1215
+ snapshot = attached.snapshot
1216
+ { name: source.name, scope: source.scope, kind: source.push? ? "push" : "cmd", every_seconds: source.every_seconds,
1217
+ why: source.why, hint: snapshot.hint || source.hint, summary: snapshot.summary, error: snapshot.error,
1218
+ fetched_at: snapshot.fetched_at, has_text: snapshot.text?, muted: own.muted?(source.name),
1219
+ unread: snapshot.text? && subs[source.name]&.read != snapshot.revision }
1220
+ end
1221
+
1120
1222
  def handle_stream(req, id)
1121
1223
  # Validate session exists
1122
1224
  begin
@@ -1491,7 +1593,14 @@ module Samagotchi
1491
1593
  role = (m[:role] || m["role"]).to_s
1492
1594
  content = (m[:content] || m["content"]).to_s
1493
1595
  if Samagotchi::ContextNote.note?(m)
1494
- filtered << { role: "note", content: Samagotchi::ContextNote.text_of(m), label: Samagotchi::ContextNote.label_of(m) }
1596
+ note = { role: "note", content: Samagotchi::ContextNote.text_of(m), label: Samagotchi::ContextNote.label_of(m) }
1597
+ # A context wake turn's note starts that turn (Steer.turn_prompt?):
1598
+ # the page opens the turn with it and pairs it with its record.
1599
+ if Samagotchi::Steer.turn_prompt?(m)
1600
+ note[:turn_start] = true
1601
+ note[:turn_id] = (m[:turn_id] || m["turn_id"]).to_s
1602
+ end
1603
+ filtered << note
1495
1604
  next
1496
1605
  end
1497
1606
  # A plugin's steer: a row of the step that answered it, which the