samagotchi 0.102.0 → 0.104.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 (106) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +93 -1
  3. data/bin/chi +60 -191
  4. data/docs/architecture.md +67 -1
  5. data/docs/cli.md +190 -9
  6. data/docs/configuration.md +7 -1
  7. data/docs/guardrails.md +4 -2
  8. data/docs/internals/context-telemetry.md +6 -2
  9. data/docs/internals/engine.md +5 -2
  10. data/docs/internals/llm-context-forget.md +11 -1
  11. data/docs/internals/participants.md +137 -0
  12. data/docs/internals/remote-sessions.md +429 -0
  13. data/docs/internals/working-set.md +95 -11
  14. data/docs/plugins.md +33 -3
  15. data/docs/sessions.md +16 -7
  16. data/docs/sub-agent.md +16 -0
  17. data/docs/testing.md +68 -4
  18. data/lib/samagotchi/answer_command.rb +27 -25
  19. data/lib/samagotchi/bridge.rb +31 -7
  20. data/lib/samagotchi/bridge_client/event_stream.rb +86 -12
  21. data/lib/samagotchi/bridge_client/stream_request.rb +77 -0
  22. data/lib/samagotchi/bridge_client.rb +26 -39
  23. data/lib/samagotchi/bundles/claude-peer/manifest.yml +2 -2
  24. data/lib/samagotchi/bundles/claude-peer/scripts/pair +30 -1
  25. data/lib/samagotchi/bundles/codex-peer/manifest.yml +2 -2
  26. data/lib/samagotchi/bundles/codex-peer/scripts/pair +49 -4
  27. data/lib/samagotchi/bundles/guardrails/guardrails/rules.yml +7 -4
  28. data/lib/samagotchi/bundles/guardrails/manifest.yml +1 -1
  29. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  30. data/lib/samagotchi/chi_invocation.rb +40 -0
  31. data/lib/samagotchi/cli/command.rb +3 -2
  32. data/lib/samagotchi/cli/flags.rb +5 -0
  33. data/lib/samagotchi/cli/parent_wait.rb +22 -13
  34. data/lib/samagotchi/context_command.rb +80 -73
  35. data/lib/samagotchi/context_status.rb +15 -2
  36. data/lib/samagotchi/engine.rb +28 -14
  37. data/lib/samagotchi/guardrail_wiring.rb +7 -1
  38. data/lib/samagotchi/idle_recap.rb +8 -2
  39. data/lib/samagotchi/idle_scheduler.rb +34 -9
  40. data/lib/samagotchi/kernel_loop.rb +21 -2
  41. data/lib/samagotchi/launch_mode.rb +26 -0
  42. data/lib/samagotchi/llm/chat_loop.rb +8 -1
  43. data/lib/samagotchi/llm_context_forget.rb +13 -6
  44. data/lib/samagotchi/note_command.rb +8 -7
  45. data/lib/samagotchi/owner_lock.rb +65 -5
  46. data/lib/samagotchi/parent_report.rb +55 -43
  47. data/lib/samagotchi/participants.rb +122 -0
  48. data/lib/samagotchi/remote_attach_command.rb +119 -0
  49. data/lib/samagotchi/remote_command.rb +170 -0
  50. data/lib/samagotchi/remotes.rb +130 -0
  51. data/lib/samagotchi/run_options.rb +245 -0
  52. data/lib/samagotchi/send_command/busy.rb +58 -0
  53. data/lib/samagotchi/send_command/parent_gate.rb +23 -10
  54. data/lib/samagotchi/send_command/wait_target.rb +16 -3
  55. data/lib/samagotchi/send_command.rb +173 -108
  56. data/lib/samagotchi/session.rb +38 -9
  57. data/lib/samagotchi/session_access/contract.rb +167 -0
  58. data/lib/samagotchi/session_access/dispatch.rb +152 -0
  59. data/lib/samagotchi/session_access/http_transport.rb +109 -0
  60. data/lib/samagotchi/session_access/local.rb +400 -0
  61. data/lib/samagotchi/session_access/remote.rb +734 -0
  62. data/lib/samagotchi/session_access/web_bridge_client.rb +236 -0
  63. data/lib/samagotchi/session_access.rb +223 -0
  64. data/lib/samagotchi/session_archive_command.rb +9 -5
  65. data/lib/samagotchi/session_commands.rb +20 -15
  66. data/lib/samagotchi/session_continue.rb +1 -1
  67. data/lib/samagotchi/session_delete_command.rb +8 -4
  68. data/lib/samagotchi/session_inbox.rb +18 -9
  69. data/lib/samagotchi/session_manager.rb +27 -6
  70. data/lib/samagotchi/session_setup.rb +11 -6
  71. data/lib/samagotchi/sessions_command.rb +36 -14
  72. data/lib/samagotchi/share_command.rb +174 -0
  73. data/lib/samagotchi/terminal_ui/attach_launcher.rb +66 -17
  74. data/lib/samagotchi/terminal_ui/attach_link.rb +114 -0
  75. data/lib/samagotchi/terminal_ui/attached_loop.rb +155 -87
  76. data/lib/samagotchi/terminal_ui/formatting.rb +13 -1
  77. data/lib/samagotchi/terminal_ui/local_link.rb +135 -0
  78. data/lib/samagotchi/terminal_ui/remote_connector.rb +66 -0
  79. data/lib/samagotchi/terminal_ui/remote_link.rb +245 -0
  80. data/lib/samagotchi/terminal_ui/version_lines.rb +28 -3
  81. data/lib/samagotchi/terminal_ui.rb +6 -3
  82. data/lib/samagotchi/testing/waiting.rb +92 -9
  83. data/lib/samagotchi/title_command.rb +9 -11
  84. data/lib/samagotchi/tools/task_runtime.rb +17 -3
  85. data/lib/samagotchi/version.rb +1 -1
  86. data/lib/samagotchi/web/app.rb +530 -126
  87. data/lib/samagotchi/web/grants.rb +302 -0
  88. data/lib/samagotchi/web/participant_access.rb +286 -0
  89. data/lib/samagotchi/web/principal.rb +29 -0
  90. data/lib/samagotchi/web/public/data.js +3 -2
  91. data/lib/samagotchi/web/public/index.html +20 -4
  92. data/lib/samagotchi/web/public/stage_frame.js +7 -2
  93. data/lib/samagotchi/web/public/stage_view.js +3 -2
  94. data/lib/samagotchi/web/public/stream_handlers.js +3 -1
  95. data/lib/samagotchi/web/public/working_set.js +159 -28
  96. data/lib/samagotchi/web/public/working_set_rail.js +39 -12
  97. data/lib/samagotchi/web/public/working_set_view.js +91 -33
  98. data/lib/samagotchi/web/server.rb +3 -1
  99. data/lib/samagotchi/web/session_access_routes.rb +459 -0
  100. data/lib/samagotchi/worker.rb +26 -5
  101. data/lib/samagotchi/worker_sidecar.rb +12 -5
  102. data/lib/samagotchi/working_set.rb +88 -14
  103. data/lib/samagotchi/working_set_command.rb +133 -30
  104. data/lib/samagotchi/working_set_pin.rb +34 -0
  105. data/lib/samagotchi/working_set_steer.rb +191 -27
  106. metadata +28 -1
@@ -19,11 +19,23 @@ module Samagotchi
19
19
  # it without that event.
20
20
  #
21
21
  # With +rediscover+, a failed connection first looks for the session's
22
- # live worker (its sidecar): a new one (a restart) is followed from its
23
- # snapshot, announced by a synthetic `{"type" => "worker_changed",
24
- # "port" => ...}` first, since its event ids start over. (A new worker
25
- # on the old one's port is the plain reconnect: its Bridge can't replay
26
- # the old ids and sends a reset.)
22
+ # live worker (its sidecar): a new one (a restart: a client with another
23
+ # BridgeClient#worker_key) is followed from its snapshot, announced by a
24
+ # synthetic `{"type" => "worker_changed", "port" => ...}` first, since its
25
+ # event ids start over. (A new worker on the old one's port is the plain
26
+ # reconnect: its Bridge can't replay the old ids and sends a reset.)
27
+ # Rediscovery may also give a reason ([nil, reason] while it retries,
28
+ # [:gone, reason] to stop): the stream_closed event carries the last one.
29
+ #
30
+ # Off by default, for a stream through chi web (SessionAccess::
31
+ # WebBridgeClient), where the client stays the same object across
32
+ # workers: +epoch_watch+ (a frame whose id has another epoch than the
33
+ # cursor's is a new worker: worker_changed first, the client's key
34
+ # updated), +announce_retries+ (stream_retrying once a reconnect fails
35
+ # and rediscovery neither moved nor ended the stream, stream_resumed
36
+ # once a stream answers again), +idle_timeout+ (no byte, not even
37
+ # the Bridge's 15 s ping, for that long: a half-open connection,
38
+ # reconnected with the cursor).
27
39
  class EventStream
28
40
  DEFAULT_RECONNECT_DELAYS = [0.1, 0.25, 0.5, 1.0, 2.0].freeze
29
41
  # Long enough for a restarted worker to boot (its Engine, the plugins).
@@ -40,12 +52,26 @@ module Samagotchi
40
52
  # @param reconnect_delays [Array<Float>] sleeps between failed attempts;
41
53
  # one more failure than there are delays gives up
42
54
  # @param rediscover [#call, nil] → a BridgeClient for the session's live
43
- # worker now, nil when there is none yet, or :gone to give up now
55
+ # worker now, nil when there is none yet, or :gone to give up now;
56
+ # or AttachLink#live_worker's pair, [one of those, a reason]
57
+ # @param epoch_watch [Boolean] see the class comment; the client takes
58
+ # worker_key= then
59
+ # @param announce_retries [Boolean] see the class comment
60
+ # @param idle_timeout [Float, nil] seconds; nil: a quiet stream is
61
+ # never cut (a Bridge on this machine)
44
62
  # @yieldparam event [Hash] string-keyed event
45
63
  def initialize(client, snapshot: true, client_id: nil, reconnect_delays: DEFAULT_RECONNECT_DELAYS, rediscover: nil,
46
- &on_event)
64
+ epoch_watch: false, announce_retries: false, idle_timeout: nil, &on_event)
47
65
  @client = client
66
+ # The worker followed: a client that keeps its object across
67
+ # workers (epoch_watch) changes its key, not itself.
68
+ @worker_key = client.worker_key
48
69
  @rediscover = rediscover
70
+ @epoch_watch = epoch_watch
71
+ @announce_retries = announce_retries
72
+ @idle_timeout = idle_timeout
73
+ @retrying = false
74
+ @reason = nil
49
75
  params = []
50
76
  params << %w[snapshot 1] if snapshot
51
77
  params << ["client_id", client_id] if client_id
@@ -96,18 +122,23 @@ module Samagotchi
96
122
 
97
123
  if outcome == :events
98
124
  failures = 0
125
+ @reason = nil
99
126
  next
100
127
  end
101
128
 
129
+ # Rediscovery first: a stopped session or a new worker is no
130
+ # retry to announce (a stop prints its line alone).
102
131
  case rediscover_worker
103
132
  when :moved
104
133
  failures = 0
134
+ @reason = nil
105
135
  next
106
- when :gone then return finish("unreachable")
136
+ when :gone then return finish(@reason || "unreachable")
107
137
  end
138
+ announce_retry
108
139
 
109
140
  failures += 1
110
- return finish("unreachable") if failures > @reconnect_delays.size
141
+ return finish(@reason || "unreachable") if failures > @reconnect_delays.size
111
142
 
112
143
  sleep(@reconnect_delays[failures - 1])
113
144
  end
@@ -125,14 +156,20 @@ module Samagotchi
125
156
  return :unknown_session if status == 404
126
157
  return :unreachable unless status == 200
127
158
 
159
+ # Back as soon as the stream answers: a replay may bring nothing
160
+ # (the session was quiet), and the next frame could be minutes away.
161
+ announce_resumed
128
162
  yielded = false
129
163
  parser = SSEParser.new
130
164
  loop do
165
+ raise Errno::ETIMEDOUT if @idle_timeout && !sock.wait_readable(@idle_timeout)
166
+
131
167
  chunk = sock.readpartial(4096)
132
168
  parser.feed(chunk) do |frame|
133
169
  event = parse(frame[:data])
134
170
  next unless event
135
171
 
172
+ watch_epoch(frame[:id]) if @epoch_watch
136
173
  @last_event_id = frame[:id] if frame[:id]
137
174
  yielded = true
138
175
  @on_event.call(event)
@@ -172,18 +209,55 @@ module Samagotchi
172
209
  def rediscover_worker
173
210
  return nil unless @rediscover
174
211
 
175
- found = @rediscover.call
212
+ found, reason = @rediscover.call
213
+ @reason = reason if reason
176
214
  return :gone if found == :gone
177
- return nil if found.nil? || found.port == @client.port
215
+ return nil if found.nil? || found.worker_key == @worker_key
178
216
 
179
217
  @client = found
180
218
  @last_event_id = nil
181
- @on_event.call({ "type" => "worker_changed", "port" => found.port }) unless closed?
219
+ moved(found.port, found.worker_key)
182
220
  :moved
183
221
  rescue StandardError
184
222
  nil
185
223
  end
186
224
 
225
+ # The frame +id+'s epoch (EventId: "<seq>-<epoch>") differs from the
226
+ # cursor's: a new worker answers the old cursor (its Bridge sends a
227
+ # reset next, or this is it). Announced before the frame.
228
+ def watch_epoch(id)
229
+ epoch = id.to_s[/-(\w+)\z/, 1]
230
+ cursor = @last_event_id.to_s[/-(\w+)\z/, 1]
231
+ return if epoch.nil? || cursor.nil? || epoch == cursor
232
+
233
+ @client.worker_key = epoch
234
+ moved(nil, epoch)
235
+ end
236
+
237
+ # Following another worker now: its key, and the worker_changed event
238
+ # (with its epoch for a stream through chi web).
239
+ def moved(port, key)
240
+ @worker_key = key
241
+ event = { "type" => "worker_changed", "port" => port }
242
+ event["epoch"] = key if @epoch_watch
243
+ @on_event.call(event) unless closed?
244
+ end
245
+
246
+ # A reconnect failed: said once, until a frame comes again.
247
+ def announce_retry
248
+ return if !@announce_retries || @retrying || closed?
249
+
250
+ @retrying = true
251
+ @on_event.call({ "type" => "stream_retrying" })
252
+ end
253
+
254
+ def announce_resumed
255
+ return unless @retrying
256
+
257
+ @retrying = false
258
+ @on_event.call({ "type" => "stream_resumed" })
259
+ end
260
+
187
261
  def finish(reason)
188
262
  @on_event.call({ "type" => "stream_closed", "reason" => reason }) unless closed?
189
263
  end
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "socket"
4
+
5
+ module Samagotchi
6
+ class BridgeClient
7
+ # The GET that opens an event stream, written by hand over a TCPSocket,
8
+ # and its response headers read up to the body: BridgeClient's stream
9
+ # (the worker's Bridge, `/session/:id/stream`), and any other server that
10
+ # serves the same SSE body at its own path with its own extra headers.
11
+ # TLS would plug in here.
12
+ module StreamRequest
13
+ # A reply's status line; its code is group 1 (BridgeClient::STATUS_LINE).
14
+ STATUS_LINE = %r{\AHTTP/1\.[01] (\d{3})}
15
+
16
+ module_function
17
+
18
+ # Open the stream and read past the response headers.
19
+ # @param host [String]
20
+ # @param port [Integer]
21
+ # @param path [String] the request path with its query
22
+ # @param last_event_id [String, nil] the reconnect cursor
23
+ # @param headers [Hash{String => String}] extra request headers, after
24
+ # Last-Event-ID
25
+ # @param timeout [Float, nil] give up when the headers aren't all in by then
26
+ # @return [Array(TCPSocket, Integer, Hash), nil] the socket positioned
27
+ # at the body, the HTTP status and the response headers (names in
28
+ # lower case), or nil when the server can't be reached
29
+ def open(host:, port:, path:, last_event_id: nil, headers: {}, timeout: nil)
30
+ sock = TCPSocket.new(host, port)
31
+ sock.write(request(host: host, port: port, path: path, last_event_id: last_event_id, headers: headers))
32
+ deadline = timeout && (Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout)
33
+ status = header_line(sock, deadline).to_s[STATUS_LINE, 1].to_i
34
+ received = {}
35
+ while (line = header_line(sock, deadline))
36
+ break if line.strip.empty?
37
+
38
+ name, value = line.split(":", 2)
39
+ received[name.strip.downcase] = value.to_s.strip if value
40
+ end
41
+ [sock, status, received]
42
+ rescue SystemCallError, SocketError, IOError
43
+ sock&.close rescue nil
44
+ nil
45
+ end
46
+
47
+ # The request's bytes.
48
+ # @return [String]
49
+ def request(host:, port:, path:, last_event_id: nil, headers: {})
50
+ lei = last_event_id.to_s.strip
51
+ last_event_line = lei.empty? ? "" : "Last-Event-ID: #{lei}\r\n"
52
+ extra = headers.map { |name, value| "#{name}: #{value}\r\n" }.join
53
+ "GET #{path} HTTP/1.1\r\nHost: #{host}:#{port}\r\nAccept: text/event-stream\r\n#{last_event_line}#{extra}" \
54
+ "Connection: keep-alive\r\n\r\n"
55
+ end
56
+
57
+ # One line of the response headers, or nil at EOF; raises
58
+ # Errno::ETIMEDOUT once +deadline+ (monotonic, nil: none) has passed. A
59
+ # line read in pieces, so a server that stops mid-line can't hold it.
60
+ def header_line(sock, deadline)
61
+ return sock.gets unless deadline
62
+
63
+ line = +""
64
+ loop do
65
+ remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
66
+ raise Errno::ETIMEDOUT unless remaining.positive? && sock.wait_readable(remaining)
67
+
68
+ char = sock.getc
69
+ return line.empty? ? nil : line if char.nil?
70
+
71
+ line << char
72
+ return line if char == "\n"
73
+ end
74
+ end
75
+ end
76
+ end
77
+ end
@@ -5,8 +5,10 @@ require "socket"
5
5
 
6
6
  require_relative "worker_sidecar"
7
7
  require_relative "delivery"
8
+ require_relative "chi_invocation"
8
9
  require_relative "bridge_client/sse_parser"
9
10
  require_relative "bridge_client/event_stream"
11
+ require_relative "bridge_client/stream_request"
10
12
 
11
13
  module Samagotchi
12
14
  # Client side of a session worker's Bridge: the 127.0.0.1 HTTP + SSE server
@@ -19,7 +21,7 @@ module Samagotchi
19
21
  class BridgeClient
20
22
  HOST = "127.0.0.1"
21
23
  # A reply's status line; its code is group 1.
22
- STATUS_LINE = %r{\AHTTP/1\.[01] (\d{3})}
24
+ STATUS_LINE = StreamRequest::STATUS_LINE
23
25
  STREAM_CONNECT_ATTEMPTS = 3
24
26
  # Seconds #stream waits for the Bridge's response headers, per attempt:
25
27
  # a worker that took the connection and froze must not hold the web's
@@ -54,9 +56,16 @@ module Samagotchi
54
56
  # What a UI says when the Bridge answers a route with 404: the worker runs
55
57
  # code from before the route. Restarting it picks up the installed chi.
56
58
  # @param cant [String] what the old worker can't do, e.g. "run commands"
57
- def self.stale_worker_message(session_id, cant:)
58
- "this session's worker runs an older chi and can't #{cant}; " \
59
- "restart it: chi sessions stop #{session_id} && chi --resume #{session_id} (its turns still work)"
59
+ # @param via [ChiInvocation] how the commands are spelled (chi --remote
60
+ # NAME: no --resume there, a message starts the fresh worker)
61
+ def self.stale_worker_message(session_id, cant:, via: ChiInvocation::HERE)
62
+ stop = via.command("sessions stop #{session_id}")
63
+ restart = if via.remote?
64
+ "#{stop}, then a message (#{via.command("send #{session_id} -m …")}) starts a fresh one"
65
+ else
66
+ "#{stop} && chi --resume #{session_id}"
67
+ end
68
+ "this session's worker runs an older chi and can't #{cant}; restart it: #{restart} (its turns still work)"
60
69
  end
61
70
 
62
71
  # Port of the live Bridge advertised in +session_dir+'s sidecar, or nil.
@@ -103,6 +112,10 @@ module Samagotchi
103
112
 
104
113
  attr_reader :session_id, :port, :host
105
114
 
115
+ # Which worker this client talks to, for EventStream: a rediscovered
116
+ # client with another key is a new worker. A Bridge's port.
117
+ def worker_key = @port
118
+
106
119
  # @param read_timeout [Numeric] see READ_TIMEOUT
107
120
  def initialize(session_id:, port:, host: HOST, read_timeout: READ_TIMEOUT)
108
121
  @session_id = session_id
@@ -152,9 +165,12 @@ module Samagotchi
152
165
  # @param no_interrupt [Boolean] run the turn with the raised iteration limit
153
166
  # @param images [Array<Hash>] refs ({file:, name:}) to images already in
154
167
  # the session's images/
155
- def post_turn(prompt:, client_id: nil, no_interrupt: false, images: nil, delivery: nil)
168
+ # @param author [String, nil] who wrote it when it isn't the session's
169
+ # owner; sent only when given (SessionManager.deliver_turn)
170
+ def post_turn(prompt:, client_id: nil, no_interrupt: false, images: nil, delivery: nil, author: nil)
156
171
  body = { session_id: @session_id, prompt: prompt, client_id: client_id }
157
172
  body[:no_interrupt] = true if no_interrupt
173
+ body[:author] = author if author
158
174
  body[:images] = images if images && !images.empty?
159
175
  body[:delivery] = delivery unless delivery.nil? || Delivery.next_step?(delivery)
160
176
  body[:deadline] = deadline
@@ -332,45 +348,16 @@ module Samagotchi
332
348
 
333
349
  # Open GET /session/:id/stream and read past the response headers.
334
350
  # @param timeout [Float, nil] give up when the headers aren't all in by then
335
- # @return [Array(TCPSocket, Integer), nil] the socket positioned at the
336
- # body and the HTTP status, or nil when the Bridge can't be reached
351
+ # @return [Array(TCPSocket, Integer, Hash), nil] the socket positioned
352
+ # at the body, the HTTP status and the headers (StreamRequest.open), or
353
+ # nil when the Bridge can't be reached
337
354
  def connect_stream(query: "", last_event_id: nil, timeout: nil)
338
- sock = TCPSocket.new(@host, @port)
339
- lei = last_event_id.to_s.strip
340
- last_event_line = lei.empty? ? "" : "Last-Event-ID: #{lei}\r\n"
341
- sock.write("GET /session/#{@session_id}/stream#{query} HTTP/1.1\r\nHost: #{@host}:#{@port}\r\nAccept: text/event-stream\r\n#{last_event_line}Connection: keep-alive\r\n\r\n")
342
- deadline = timeout && (Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout)
343
- status = header_line(sock, deadline).to_s[STATUS_LINE, 1].to_i
344
- while (line = header_line(sock, deadline))
345
- break if line.strip.empty?
346
- end
347
- [sock, status]
348
- rescue SystemCallError, SocketError, IOError
349
- sock&.close rescue nil
350
- nil
355
+ StreamRequest.open(host: @host, port: @port, path: "/session/#{@session_id}/stream#{query}",
356
+ last_event_id: last_event_id, timeout: timeout)
351
357
  end
352
358
 
353
359
  private
354
360
 
355
- # One line of the response headers, or nil at EOF; raises
356
- # Errno::ETIMEDOUT once +deadline+ (monotonic, nil: none) has passed. A
357
- # line read in pieces, so a Bridge that stops mid-line can't hold it.
358
- def header_line(sock, deadline)
359
- return sock.gets unless deadline
360
-
361
- line = +""
362
- loop do
363
- remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
364
- raise Errno::ETIMEDOUT unless remaining.positive? && sock.wait_readable(remaining)
365
-
366
- char = sock.getc
367
- return line.empty? ? nil : line if char.nil?
368
-
369
- line << char
370
- return line if char == "\n"
371
- end
372
- end
373
-
374
361
  # When this client stops waiting for a reply, as epoch seconds (see
375
362
  # DEADLINE_SHARE). Wall clock: the worker runs on this machine and reads
376
363
  # the same one.
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: claude-peer
3
- version: 0.4.1
3
+ version: 0.4.2
4
4
  scope: system
5
5
  description: "Pair this session with a Claude Code session you work with: the claude_send tool and /claude send it a part of the work (a plan step, a diff, a finding) to validate or as context, and Claude's answer comes back as the claude-outbox context; /claude install-skill installs Claude's chi-peer skill, which pairs"
6
6
  trust_level: reviewed
@@ -12,7 +12,7 @@ plugin:
12
12
  file: plugin.rb
13
13
  sha256: sha256:50f427d3862ae9d0d15693b4d887c528162b96c0320cc663df89d59d830d4660
14
14
  scripts:
15
- pair: sha256:35f3be2b466a5ba07591d64040bc5780fdd843b9b08737f8ea8a2fccb2102832
15
+ pair: sha256:2e66103c43fce5eab05f897682ba943f394743c1ad47bf439be3ce529f1b2058
16
16
  chi-peer-SKILL.md: sha256:e991a91139a127a60f9a216043eb5864945fb211952a1e3c8db2068c6cc254f1
17
17
  # The plugin needs Samagotchi::Plugin::Peer and chi.peer_source (chi 0.67.0).
18
18
  # An agreeing answer's closing wake (the skills say an OK wakes chi once)
@@ -8,7 +8,9 @@
8
8
  # is this session's inbox socket (chi's claude_send tool writes there), and
9
9
  # claude-outbox, where Claude's answers go. Again after a Claude restart, it
10
10
  # only refreshes the socket: the push carries the new hint. CHI names the chi
11
- # command (default: chi on the PATH).
11
+ # command (default: chi on the PATH). Through a remote (CHI_REMOTE, or
12
+ # --remote NAME in CHI) chi there can't reach this machine's socket: it
13
+ # attaches claude-outbox only, and says so.
12
14
  set -eu
13
15
 
14
16
  usage() {
@@ -33,6 +35,23 @@ if [ -z "$sock" ]; then
33
35
  fi
34
36
  chi=${CHI:-chi}
35
37
 
38
+ # The remote, as SessionAccess::Dispatch names it: a --remote NAME (or
39
+ # --remote=NAME) word in CHI, the last one, beats CHI_REMOTE (trimmed; blank:
40
+ # local). An empty or dash-led name is still a remote: chi refuses it.
41
+ remote=$(printf '%s\n' "${CHI_REMOTE:-}" | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//')
42
+ via= take=
43
+ [ -z "$remote" ] || via=env
44
+ for word in $chi; do
45
+ if [ -n "$take" ]; then
46
+ remote=$word take=
47
+ continue
48
+ fi
49
+ case "$word" in
50
+ --remote) remote= via=chi take=1 ;;
51
+ --remote=*) remote=${word#--remote=} via=chi ;;
52
+ esac
53
+ done
54
+
36
55
  # A JSON string: backslashes, quotes, tabs and newlines escaped.
37
56
  json() {
38
57
  printf '%s' "$1" | sed -e 's/\\/\\\\/g' -e 's/"/\\"/g' -e 's/ /\\t/g' |
@@ -51,6 +70,16 @@ attach() {
51
70
  fi
52
71
  }
53
72
 
73
+ if [ -n "$via" ]; then
74
+ attach claude-outbox --why "Claude's answers to what you sent it (claude-peer); Claude runs on another machine: claude_send can't reach it" \
75
+ --hint "latest Claude reply"
76
+ ask=$chi
77
+ [ "$via" = chi ] || ask="CHI_REMOTE=$remote $chi"
78
+ printf '%s\n' "pair: chi is on $remote, so chi can't send to this Claude session (claude_send needs its socket on chi's machine): paired with chi $id, claude-outbox only. Answer chi with $ask context push claude-outbox $id; chi's replies reach you only when the user relays them (or asks you to run $ask send --wait $id)"
79
+ $chi context ls "$id"
80
+ exit 0
81
+ fi
82
+
54
83
  attach claude-inbox --why "the Claude Code session paired with you: send it parts with claude_send" --hint "$sock"
55
84
  text="Claude Code peer inbox
56
85
  socket: $sock
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: codex-peer
3
- version: 0.3.2
3
+ version: 0.3.3
4
4
  scope: system
5
5
  description: "Pair this session with an OpenAI Codex CLI session you work with: the codex_send tool and /codex send it a part of the work (a plan step, a diff, a finding) to validate or as context, with codex queue, and Codex's answer comes back as the codex-outbox context; /codex install-skill installs Codex's chi-peer skill, which pairs"
6
6
  trust_level: reviewed
@@ -12,7 +12,7 @@ plugin:
12
12
  file: plugin.rb
13
13
  sha256: sha256:b03465566272891237ac1906980ff6956579a4703d3a42c414ce50a81de551f6
14
14
  scripts:
15
- pair: sha256:251448a31959b5c916400eff7909e85c696855d5c16b6b10973ada40915c8051
15
+ pair: sha256:0830dcb856debea0e4a2a0eb0da8a73b9e9ac2444da957d2928ec734280186a1
16
16
  chi-peer-SKILL.md: sha256:7c392ac04c115ea1e762ad562b5c4c44c3eca71558ba5d9dfeaeabdd533b6a82
17
17
  # The plugin needs Samagotchi::Plugin::Peer and chi.peer_source (chi 0.67.0).
18
18
  # An agreeing answer's closing wake (the skills say an OK wakes chi once)
@@ -9,12 +9,15 @@
9
9
  # `codex queue --thread <id>` with it), and codex-outbox, where Codex's
10
10
  # answers go. Again later, it only refreshes the thread id: the push carries
11
11
  # the new hint. CHI names the chi command (default: chi on the PATH).
12
+ # Through a remote (CHI_REMOTE, or --remote NAME in CHI) chi there can't
13
+ # queue to this machine's Codex: it attaches codex-outbox only, and says so.
12
14
  #
13
15
  # chi writes the session's context under its state folder, which Codex's
14
16
  # default sandbox doesn't let it write: ~/.codex/config.toml needs
15
17
  # [sandbox_workspace_write]
16
18
  # writable_roots = ["<state>/samagotchi/context"]
17
- # and a write refused says so.
19
+ # and a write refused says so. Through a remote chi talks HTTP instead,
20
+ # which the sandbox may refuse (no network): a connection that fails says so.
18
21
  set -eu
19
22
 
20
23
  usage() {
@@ -39,14 +42,46 @@ if [ -z "$thread" ]; then
39
42
  fi
40
43
  chi=${CHI:-chi}
41
44
 
42
- # Prints chi's error, and the writable_roots line when the sandbox refused.
45
+ # The remote, as SessionAccess::Dispatch names it: a --remote NAME (or
46
+ # --remote=NAME) word in CHI, the last one, beats CHI_REMOTE (trimmed; blank:
47
+ # local). An empty or dash-led name is still a remote: chi refuses it.
48
+ remote=$(printf '%s\n' "${CHI_REMOTE:-}" | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//')
49
+ via= take=
50
+ [ -z "$remote" ] || via=env
51
+ for word in $chi; do
52
+ if [ -n "$take" ]; then
53
+ remote=$word take=
54
+ continue
55
+ fi
56
+ case "$word" in
57
+ --remote) remote= via=chi take=1 ;;
58
+ --remote=*) remote=${word#--remote=} via=chi ;;
59
+ esac
60
+ done
61
+
62
+ config='~/.codex/config.toml'
63
+ [ -z "${CODEX_HOME:-}" ] || config=$CODEX_HOME/config.toml
64
+
65
+ # Prints chi's error, and the config line when the sandbox may have refused:
66
+ # writable_roots here, network_access through a remote.
43
67
  fail() {
44
68
  printf '%s\n' "$1" >&2
69
+ if [ -n "$via" ]; then
70
+ case "$1" in
71
+ *"connection broke"* | *"can't find host"* | *"host unreachable"* | *"no answer within"*)
72
+ cat >&2 <<MSG
73
+ pair: Codex's sandbox may be keeping chi from reaching $remote (workspace-write has no network by default): if so, add to $config
74
+ [sandbox_workspace_write]
75
+ network_access = true
76
+ then restart Codex and pair again.
77
+ MSG
78
+ ;;
79
+ esac
80
+ exit 1
81
+ fi
45
82
  case "$1" in
46
83
  *"Operation not permitted"* | *"Read-only file system"* | *"Permission denied"*)
47
84
  state=${XDG_STATE_HOME:-$HOME/.local/state}
48
- config='~/.codex/config.toml'
49
- [ -z "${CODEX_HOME:-}" ] || config=$CODEX_HOME/config.toml
50
85
  cat >&2 <<MSG
51
86
  pair: Codex's sandbox can't write chi's state: add to $config
52
87
  [sandbox_workspace_write]
@@ -75,6 +110,16 @@ attach() {
75
110
  fi
76
111
  }
77
112
 
113
+ if [ -n "$via" ]; then
114
+ attach codex-outbox --why "Codex's answers to what you sent it (codex-peer); Codex runs on another machine: codex_send can't reach it" \
115
+ --hint "latest Codex reply"
116
+ ask=$chi
117
+ [ "$via" = chi ] || ask="CHI_REMOTE=$remote $chi"
118
+ printf '%s\n' "pair: chi is on $remote, so chi can't send to this Codex session (codex_send runs codex queue on chi's machine): paired with chi $id, codex-outbox only. Answer chi with $ask context push codex-outbox $id; chi's replies reach you only when the user relays them (or asks you to run $ask send --wait $id)"
119
+ $chi context ls "$id"
120
+ exit 0
121
+ fi
122
+
78
123
  attach codex-inbox --why "the Codex session paired with you: send it parts with codex_send" --hint "$thread"
79
124
  text="Codex peer inbox
80
125
  thread: $thread
@@ -154,10 +154,12 @@ rules:
154
154
  # code). add NAME --push and chi context push don't run anything. Quoted
155
155
  # words ("context", '--cmd=…') and backslash-newline continuations still
156
156
  # match; xargs chi context add asks whatever it is fed (its arguments
157
- # aren't in the command).
157
+ # aren't in the command). chi's global flag --remote NAME (the only one
158
+ # that goes before a subcommand) may stand between chi and context;
159
+ # CHI_REMOTE=NAME leaves the words as they are.
158
160
  - id: chi-context-cmd
159
161
  tool: shell
160
- command: '\bchi["'']?[\s\\]+["'']?context["'']?[\s\\]+["'']?add\b(?:[^\n]|\\\n)*[\s"''\\]--cmd(?:[\s="''\\]|$)|\bxargs\b[^;&|\n]*\bchi["'']?[\s\\]+["'']?context["'']?[\s\\]+["'']?add\b'
162
+ command: '\bchi["'']?(?:[\s\\]+["'']?--remote(?:=|["'']?[\s\\]+["'']?)[^\s"''\\;&|]+["'']?)*[\s\\]+["'']?context["'']?[\s\\]+["'']?add\b(?:[^\n]|\\\n)*[\s"''\\]--cmd(?:[\s="''\\]|$)|\bxargs\b[^;&|\n]*\bchi["'']?(?:[\s\\]+["'']?--remote(?:=|["'']?[\s\\]+["'']?)[^\s"''\\;&|]+["'']?)*[\s\\]+["'']?context["'']?[\s\\]+["'']?add\b'
161
163
  verdict: ask
162
164
  scopes: [once]
163
165
  reason: attaches a command chi will run every few minutes, outside the guardrails
@@ -165,10 +167,11 @@ rules:
165
167
  # ── chi broadcast: for the user, not an agent ─────────────────────────
166
168
  # chi broadcast refuses to run inside a session (SAMAGOTCHI_PARENT_SESSION
167
169
  # is set there), but an agent can unset that; this asks every time, and
168
- # only the user may allow it. Quoted words and continuations still match.
170
+ # only the user may allow it. Quoted words, continuations and --remote
171
+ # NAME before the word still match.
169
172
  - id: chi-broadcast
170
173
  tool: shell
171
- command: '\bchi["'']?[\s\\]+["'']?broadcast\b'
174
+ command: '\bchi["'']?(?:[\s\\]+["'']?--remote(?:=|["'']?[\s\\]+["'']?)[^\s"''\\;&|]+["'']?)*[\s\\]+["'']?broadcast\b'
172
175
  verdict: ask
173
176
  scopes: [once]
174
177
  reason: shares a note with every session it may concern; chi broadcast is for the user, not an agent
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: guardrails
3
- version: 0.9.2
3
+ version: 0.9.3
4
4
  scope: system
5
5
  description: Optional default guardrail rules (git push/rewrites, rm -rf wide, curl | sh, git hooks, chi's own config and attached context from the shell, chi context add --cmd, chi broadcast, answering chi around chi answer, removing a memory, writing a memory that reaches the system prompt (identity, model notes) or a model overlay; in strict mode also git rebase and writes and git in another checkout outside the session's repo), asks only small models get (git checkout --/restore, git stash drop), and a note for the model
6
6
  trust_level: reviewed
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: samagotchi-system
3
- version: 0.102.0
3
+ version: 0.104.0
4
4
  scope: system
5
5
  description: Default system memories — identity, self map, config modification protocol, memory guide and the delegated-session rules
6
6
  files:
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Samagotchi
4
+ # How the follow-up commands chi prints (ParentReport's chi answer and
5
+ # chi send --wait, a stop's chi --resume, the attached TUI's re-attach
6
+ # lines) are spelled so that they work when pasted: on this machine plain
7
+ # chi, against a remote (chi --remote NAME, CHI_REMOTE=NAME alike) chi
8
+ # --remote NAME. A remote's chi web is named where the page is meant.
9
+ # @!attribute remote [String, nil] the remote's name; nil: this machine
10
+ ChiInvocation = Data.define(:remote) do
11
+ def remote? = !remote.nil?
12
+
13
+ # The command word: "chi", or "chi --remote NAME".
14
+ def chi = remote? ? "chi --remote #{remote}" : "chi"
15
+
16
+ # +words+ after #chi: command("answer ID …").
17
+ def command(words) = "#{chi} #{words}"
18
+
19
+ # chi --attach ID (chi --remote NAME --attach ID).
20
+ def attach(id) = command("--attach #{id}")
21
+
22
+ # chi --resume ID (chi --remote NAME --resume ID).
23
+ def resume(id) = command("--resume #{id}")
24
+
25
+ # What shows the session ("… shows it"): its attach.
26
+ def shows(id) = attach(id)
27
+
28
+ # Where the user opens it ("open it: …"): its attach, or the web (the
29
+ # remote's chi web).
30
+ def open_it(id) = "#{attach(id)} or #{remote? ? web : "the web"}"
31
+
32
+ # The remote's chi web, by name.
33
+ def web = "chi web on #{remote}"
34
+ end
35
+
36
+ class ChiInvocation
37
+ # This machine's: plain chi.
38
+ HERE = new(remote: nil)
39
+ end
40
+ end
@@ -26,9 +26,10 @@ module Samagotchi
26
26
  @stderr.puts(text)
27
27
  end
28
28
 
29
+ # @param usage [String, nil] what follows the line (nil: "(see …
30
+ # --help)" on it); a one-line error whose fix the line says passes nil
29
31
  # @return [Integer] the usage exit status
30
- def usage_error(message)
31
- usage = usage_on_error
32
+ def usage_error(message, usage: usage_on_error)
32
33
  error_line("#{command_name}: #{message}#{" (see #{command_name} --help)" unless usage}")
33
34
  @stderr.puts(usage) if usage
34
35
  Exit::USAGE
@@ -68,6 +68,11 @@ module Samagotchi
68
68
  add(names, Flag.new(refusal: message))
69
69
  end
70
70
 
71
+ # Each declared flag's names, in the order declared ([["-m",
72
+ # "--message"], ["--new"]]); refused flags too.
73
+ # @return [Array<Array<String>>]
74
+ def names = @flags.keys.group_by { |name| @flags[name].object_id }.values
75
+
71
76
  # @param defaults [Hash] the options before any flag (not changed)
72
77
  # @return [Result]
73
78
  def parse(argv, defaults = {})