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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +93 -1
- data/bin/chi +60 -191
- data/docs/architecture.md +67 -1
- data/docs/cli.md +190 -9
- data/docs/configuration.md +7 -1
- data/docs/guardrails.md +4 -2
- data/docs/internals/context-telemetry.md +6 -2
- data/docs/internals/engine.md +5 -2
- data/docs/internals/llm-context-forget.md +11 -1
- data/docs/internals/participants.md +137 -0
- data/docs/internals/remote-sessions.md +429 -0
- data/docs/internals/working-set.md +95 -11
- data/docs/plugins.md +33 -3
- data/docs/sessions.md +16 -7
- data/docs/sub-agent.md +16 -0
- data/docs/testing.md +68 -4
- data/lib/samagotchi/answer_command.rb +27 -25
- data/lib/samagotchi/bridge.rb +31 -7
- data/lib/samagotchi/bridge_client/event_stream.rb +86 -12
- data/lib/samagotchi/bridge_client/stream_request.rb +77 -0
- data/lib/samagotchi/bridge_client.rb +26 -39
- data/lib/samagotchi/bundles/claude-peer/manifest.yml +2 -2
- data/lib/samagotchi/bundles/claude-peer/scripts/pair +30 -1
- data/lib/samagotchi/bundles/codex-peer/manifest.yml +2 -2
- data/lib/samagotchi/bundles/codex-peer/scripts/pair +49 -4
- data/lib/samagotchi/bundles/guardrails/guardrails/rules.yml +7 -4
- data/lib/samagotchi/bundles/guardrails/manifest.yml +1 -1
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/chi_invocation.rb +40 -0
- data/lib/samagotchi/cli/command.rb +3 -2
- data/lib/samagotchi/cli/flags.rb +5 -0
- data/lib/samagotchi/cli/parent_wait.rb +22 -13
- data/lib/samagotchi/context_command.rb +80 -73
- data/lib/samagotchi/context_status.rb +15 -2
- data/lib/samagotchi/engine.rb +28 -14
- data/lib/samagotchi/guardrail_wiring.rb +7 -1
- data/lib/samagotchi/idle_recap.rb +8 -2
- data/lib/samagotchi/idle_scheduler.rb +34 -9
- data/lib/samagotchi/kernel_loop.rb +21 -2
- data/lib/samagotchi/launch_mode.rb +26 -0
- data/lib/samagotchi/llm/chat_loop.rb +8 -1
- data/lib/samagotchi/llm_context_forget.rb +13 -6
- data/lib/samagotchi/note_command.rb +8 -7
- data/lib/samagotchi/owner_lock.rb +65 -5
- data/lib/samagotchi/parent_report.rb +55 -43
- data/lib/samagotchi/participants.rb +122 -0
- data/lib/samagotchi/remote_attach_command.rb +119 -0
- data/lib/samagotchi/remote_command.rb +170 -0
- data/lib/samagotchi/remotes.rb +130 -0
- data/lib/samagotchi/run_options.rb +245 -0
- data/lib/samagotchi/send_command/busy.rb +58 -0
- data/lib/samagotchi/send_command/parent_gate.rb +23 -10
- data/lib/samagotchi/send_command/wait_target.rb +16 -3
- data/lib/samagotchi/send_command.rb +173 -108
- data/lib/samagotchi/session.rb +38 -9
- data/lib/samagotchi/session_access/contract.rb +167 -0
- data/lib/samagotchi/session_access/dispatch.rb +152 -0
- data/lib/samagotchi/session_access/http_transport.rb +109 -0
- data/lib/samagotchi/session_access/local.rb +400 -0
- data/lib/samagotchi/session_access/remote.rb +734 -0
- data/lib/samagotchi/session_access/web_bridge_client.rb +236 -0
- data/lib/samagotchi/session_access.rb +223 -0
- data/lib/samagotchi/session_archive_command.rb +9 -5
- data/lib/samagotchi/session_commands.rb +20 -15
- data/lib/samagotchi/session_continue.rb +1 -1
- data/lib/samagotchi/session_delete_command.rb +8 -4
- data/lib/samagotchi/session_inbox.rb +18 -9
- data/lib/samagotchi/session_manager.rb +27 -6
- data/lib/samagotchi/session_setup.rb +11 -6
- data/lib/samagotchi/sessions_command.rb +36 -14
- data/lib/samagotchi/share_command.rb +174 -0
- data/lib/samagotchi/terminal_ui/attach_launcher.rb +66 -17
- data/lib/samagotchi/terminal_ui/attach_link.rb +114 -0
- data/lib/samagotchi/terminal_ui/attached_loop.rb +155 -87
- data/lib/samagotchi/terminal_ui/formatting.rb +13 -1
- data/lib/samagotchi/terminal_ui/local_link.rb +135 -0
- data/lib/samagotchi/terminal_ui/remote_connector.rb +66 -0
- data/lib/samagotchi/terminal_ui/remote_link.rb +245 -0
- data/lib/samagotchi/terminal_ui/version_lines.rb +28 -3
- data/lib/samagotchi/terminal_ui.rb +6 -3
- data/lib/samagotchi/testing/waiting.rb +92 -9
- data/lib/samagotchi/title_command.rb +9 -11
- data/lib/samagotchi/tools/task_runtime.rb +17 -3
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +530 -126
- data/lib/samagotchi/web/grants.rb +302 -0
- data/lib/samagotchi/web/participant_access.rb +286 -0
- data/lib/samagotchi/web/principal.rb +29 -0
- data/lib/samagotchi/web/public/data.js +3 -2
- data/lib/samagotchi/web/public/index.html +20 -4
- data/lib/samagotchi/web/public/stage_frame.js +7 -2
- data/lib/samagotchi/web/public/stage_view.js +3 -2
- data/lib/samagotchi/web/public/stream_handlers.js +3 -1
- data/lib/samagotchi/web/public/working_set.js +159 -28
- data/lib/samagotchi/web/public/working_set_rail.js +39 -12
- data/lib/samagotchi/web/public/working_set_view.js +91 -33
- data/lib/samagotchi/web/server.rb +3 -1
- data/lib/samagotchi/web/session_access_routes.rb +459 -0
- data/lib/samagotchi/worker.rb +26 -5
- data/lib/samagotchi/worker_sidecar.rb +12 -5
- data/lib/samagotchi/working_set.rb +88 -14
- data/lib/samagotchi/working_set_command.rb +133 -30
- data/lib/samagotchi/working_set_pin.rb +34 -0
- data/lib/samagotchi/working_set_steer.rb +191 -27
- 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
|
|
23
|
-
# snapshot, announced by a
|
|
24
|
-
# "port" => ...}` first, since its
|
|
25
|
-
# on the old one's port is the plain
|
|
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.
|
|
215
|
+
return nil if found.nil? || found.worker_key == @worker_key
|
|
178
216
|
|
|
179
217
|
@client = found
|
|
180
218
|
@last_event_id = nil
|
|
181
|
-
|
|
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 =
|
|
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
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
-
|
|
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
|
|
336
|
-
# body
|
|
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
|
-
|
|
339
|
-
|
|
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.
|
|
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:
|
|
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.
|
|
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:
|
|
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
|
-
#
|
|
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
|
|
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.
|
|
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
|
|
@@ -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
|
data/lib/samagotchi/cli/flags.rb
CHANGED
|
@@ -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 = {})
|