samagotchi 0.121.0 → 0.122.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 (127) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +110 -1
  3. data/bin/chi +7 -3
  4. data/docs/architecture.md +37 -11
  5. data/docs/bundle-authoring.md +2 -1
  6. data/docs/cli.md +11 -4
  7. data/docs/configuration.md +36 -7
  8. data/docs/guardrails.md +63 -19
  9. data/docs/internals/anthropic.md +1 -1
  10. data/docs/internals/background-tasks.md +1 -1
  11. data/docs/internals/engine.md +2 -0
  12. data/docs/internals/participants.md +16 -8
  13. data/docs/internals/remote-sessions.md +15 -11
  14. data/docs/internals/web-push.md +22 -11
  15. data/docs/memory.md +33 -0
  16. data/docs/plugins.md +8 -3
  17. data/docs/sessions.md +21 -0
  18. data/docs/testing.md +41 -1
  19. data/lib/samagotchi/answer_command.rb +1 -1
  20. data/lib/samagotchi/bridge/turn_accumulator.rb +3 -1
  21. data/lib/samagotchi/bridge.rb +12 -11
  22. data/lib/samagotchi/bridge_client/event_stream.rb +14 -26
  23. data/lib/samagotchi/bridge_client/stream_open.rb +85 -0
  24. data/lib/samagotchi/bridge_client/stream_request.rb +8 -2
  25. data/lib/samagotchi/bridge_client.rb +52 -65
  26. data/lib/samagotchi/bridge_secret.rb +9 -7
  27. data/lib/samagotchi/bundle_command/check.rb +27 -2
  28. data/lib/samagotchi/bundles/claude-peer/manifest.yml +2 -2
  29. data/lib/samagotchi/bundles/claude-peer/scripts/pair +20 -6
  30. data/lib/samagotchi/bundles/codex-peer/manifest.yml +2 -2
  31. data/lib/samagotchi/bundles/codex-peer/scripts/pair +14 -3
  32. data/lib/samagotchi/bundles/github-pr/manifest.yml +2 -2
  33. data/lib/samagotchi/bundles/github-pr/plugin.rb +2 -6
  34. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  35. data/lib/samagotchi/bundles/system/memory_guide.md +13 -0
  36. data/lib/samagotchi/child_move.rb +5 -7
  37. data/lib/samagotchi/children_status.rb +2 -1
  38. data/lib/samagotchi/config.rb +5 -0
  39. data/lib/samagotchi/engine.rb +107 -1
  40. data/lib/samagotchi/event_fanout.rb +0 -1
  41. data/lib/samagotchi/guardrail_wiring.rb +24 -6
  42. data/lib/samagotchi/guardrails/parent_approvals.rb +9 -0
  43. data/lib/samagotchi/guardrails_command.rb +41 -6
  44. data/lib/samagotchi/image_store.rb +6 -3
  45. data/lib/samagotchi/interrupted_turn.rb +6 -3
  46. data/lib/samagotchi/live_versions.rb +6 -7
  47. data/lib/samagotchi/log_line.rb +1 -1
  48. data/lib/samagotchi/memory_bundle/profile.rb +1 -1
  49. data/lib/samagotchi/memory_bundle/uninstaller.rb +6 -2
  50. data/lib/samagotchi/memory_orphans.rb +11 -0
  51. data/lib/samagotchi/participant_mode.rb +11 -0
  52. data/lib/samagotchi/plugin/api.rb +14 -8
  53. data/lib/samagotchi/plugin/sessions.rb +13 -4
  54. data/lib/samagotchi/process_group.rb +13 -0
  55. data/lib/samagotchi/push_command.rb +1 -1
  56. data/lib/samagotchi/relay_verifier.rb +2 -3
  57. data/lib/samagotchi/relay_watcher.rb +2 -2
  58. data/lib/samagotchi/remote_attach_command.rb +1 -1
  59. data/lib/samagotchi/reply_wait/poll.rb +8 -2
  60. data/lib/samagotchi/reply_wait.rb +4 -3
  61. data/lib/samagotchi/review_command.rb +1 -1
  62. data/lib/samagotchi/review_start.rb +6 -2
  63. data/lib/samagotchi/run_options.rb +2 -0
  64. data/lib/samagotchi/session.rb +18 -1
  65. data/lib/samagotchi/session_access/contract.rb +1 -1
  66. data/lib/samagotchi/session_access/local.rb +18 -8
  67. data/lib/samagotchi/session_access/remote.rb +37 -12
  68. data/lib/samagotchi/session_access/web_bridge_client.rb +38 -19
  69. data/lib/samagotchi/session_access.rb +21 -9
  70. data/lib/samagotchi/session_continue.rb +2 -3
  71. data/lib/samagotchi/session_manager.rb +133 -29
  72. data/lib/samagotchi/session_setup.rb +8 -4
  73. data/lib/samagotchi/sessions_command.rb +10 -5
  74. data/lib/samagotchi/steer.rb +7 -6
  75. data/lib/samagotchi/system_one/client.rb +12 -6
  76. data/lib/samagotchi/system_one/fake_backend.rb +0 -2
  77. data/lib/samagotchi/system_one/log.rb +44 -9
  78. data/lib/samagotchi/system_one/self_protect.rb +39 -43
  79. data/lib/samagotchi/terminal_ui/attach_launcher.rb +8 -3
  80. data/lib/samagotchi/terminal_ui/attached_loop.rb +4 -2
  81. data/lib/samagotchi/terminal_ui/local_link.rb +28 -19
  82. data/lib/samagotchi/terminal_ui.rb +2 -1
  83. data/lib/samagotchi/tool_runner.rb +2 -1
  84. data/lib/samagotchi/toolkit.rb +95 -10
  85. data/lib/samagotchi/toolkit_binding.rb +115 -7
  86. data/lib/samagotchi/toolkit_command.rb +15 -3
  87. data/lib/samagotchi/toolkit_report_command.rb +318 -0
  88. data/lib/samagotchi/toolkit_suggest.rb +211 -0
  89. data/lib/samagotchi/tools/delegate.rb +1 -1
  90. data/lib/samagotchi/tools/delegate_relay.rb +9 -7
  91. data/lib/samagotchi/tools/memory.rb +1 -1
  92. data/lib/samagotchi/turn_checkpoint.rb +15 -9
  93. data/lib/samagotchi/version.rb +1 -1
  94. data/lib/samagotchi/web/app.rb +127 -69
  95. data/lib/samagotchi/web/participant_access.rb +30 -8
  96. data/lib/samagotchi/web/participant_review.rb +8 -5
  97. data/lib/samagotchi/web/participant_view.rb +7 -3
  98. data/lib/samagotchi/web/participants_admin.rb +13 -0
  99. data/lib/samagotchi/web/principal.rb +3 -4
  100. data/lib/samagotchi/web/public/app.js +1 -1
  101. data/lib/samagotchi/web/public/bubbles.js +3 -2
  102. data/lib/samagotchi/web/public/notifications.js +21 -1
  103. data/lib/samagotchi/web/public/participant.html +3 -2
  104. data/lib/samagotchi/web/public/participant.js +25 -10
  105. data/lib/samagotchi/web/public/participants_panel.js +46 -11
  106. data/lib/samagotchi/web/public/push.js +4 -3
  107. data/lib/samagotchi/web/public/push_devices.js +17 -3
  108. data/lib/samagotchi/web/public/review_history.js +14 -2
  109. data/lib/samagotchi/web/public/review_start.js +1 -3
  110. data/lib/samagotchi/web/public/session_actions.js +14 -2
  111. data/lib/samagotchi/web/public/storage.js +16 -0
  112. data/lib/samagotchi/web/public/turn_events.js +2 -1
  113. data/lib/samagotchi/web/public/turn_html.js +1 -1
  114. data/lib/samagotchi/web/push/client.rb +5 -0
  115. data/lib/samagotchi/web/push/store.rb +16 -3
  116. data/lib/samagotchi/web/push_routes.rb +3 -2
  117. data/lib/samagotchi/web/server.rb +2 -2
  118. data/lib/samagotchi/web/session_access_routes.rb +28 -20
  119. data/lib/samagotchi/web/session_control.rb +4 -2
  120. data/lib/samagotchi/web/session_create.rb +4 -4
  121. data/lib/samagotchi/web/session_hub.rb +6 -4
  122. data/lib/samagotchi/web/session_summary.rb +8 -16
  123. data/lib/samagotchi/web/start_dir.rb +10 -5
  124. data/lib/samagotchi/worker.rb +17 -3
  125. data/lib/samagotchi/worker_link.rb +214 -0
  126. data/lib/samagotchi/worker_sidecar.rb +15 -37
  127. metadata +6 -1
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require_relative "../bridge_secret"
5
+
6
+ module Samagotchi
7
+ class BridgeClient
8
+ # The result of opening an SSE connection. Only Opened owns a socket the
9
+ # caller may read; refused sockets are consumed for their detail and closed.
10
+ module StreamOpen
11
+ Opened = Data.define(:sock, :status, :headers)
12
+ Refused = Data.define(:reason, :detail, :status)
13
+ Unreachable = Data.define(:attempts, :detail) do
14
+ def initialize(attempts:, detail: nil) = super
15
+ end
16
+
17
+ BODY_LIMIT = 16_384
18
+ BODY_TIMEOUT = 5.0
19
+
20
+ module_function
21
+
22
+ # The one place a stream's response status is interpreted. Through chi
23
+ # web, its mapped bridge-secret and not-live responses have their own
24
+ # status codes (502 and 503).
25
+ def classify(sock, status, headers)
26
+ return Unreachable.new(attempts: 0) unless sock
27
+ return Opened.new(sock: sock, status: 200, headers: headers) if status.to_i == 200
28
+
29
+ if status.to_i == 503
30
+ json = parse(refusal_body(sock, headers))
31
+ return Unreachable.new(attempts: 1, detail: json&.[]("detail"))
32
+ end
33
+
34
+ body = refusal_body(sock, headers)
35
+ json = parse(body)
36
+ detail = json&.[]("detail") || (body unless body.empty?)
37
+ code = status.to_i
38
+ error = json&.[]("error")
39
+ outcome = if code == 404
40
+ Refused.new(reason: :unknown_session, detail: detail, status: code)
41
+ elsif BridgeSecret.refused?(code, json || body)
42
+ Refused.new(reason: :bridge_secret, detail: detail, status: code)
43
+ elsif code == 403 && error == "proxied"
44
+ Refused.new(reason: :proxied, detail: detail, status: code)
45
+ elsif code == 403 && error == "cross_origin"
46
+ Refused.new(reason: :cross_origin, detail: detail, status: code)
47
+ elsif code == 502 && error == BridgeSecret::ERROR
48
+ Refused.new(reason: :bridge_secret, detail: detail, status: code)
49
+ else
50
+ Refused.new(reason: :other, detail: detail, status: code)
51
+ end
52
+ outcome
53
+ ensure
54
+ sock&.close unless (status || 0).to_i == 200
55
+ end
56
+
57
+ # A refusal body is bounded and interruptible by closing its socket.
58
+ # Content-Length replies stop at their declared size; older Bridges
59
+ # without it are read through EOF.
60
+ def refusal_body(sock, headers)
61
+ length = headers.to_h["content-length"]&.to_i
62
+ limit = length&.between?(0, BODY_LIMIT) ? length : BODY_LIMIT
63
+ body = +""
64
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + BODY_TIMEOUT
65
+ while body.bytesize < limit
66
+ left = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
67
+ break unless left.positive? && sock.wait_readable(left)
68
+
69
+ body << sock.readpartial(limit - body.bytesize)
70
+ end
71
+ body.force_encoding(Encoding::UTF_8).scrub
72
+ rescue IOError, SystemCallError
73
+ body.force_encoding(Encoding::UTF_8).scrub
74
+ end
75
+
76
+ def parse(body)
77
+ json = JSON.parse(body)
78
+ json if json.is_a?(Hash)
79
+ rescue JSON::ParserError
80
+ nil
81
+ end
82
+ private_class_method :parse
83
+ end
84
+ end
85
+ end
@@ -32,8 +32,14 @@ module Samagotchi
32
32
  # @return [Array(TCPSocket, Integer, Hash), nil] the socket positioned
33
33
  # at the body, the HTTP status and the response headers (names in
34
34
  # lower case), or nil when the server can't be reached
35
+ # @raise [Errno::ETIMEDOUT] a connected server didn't finish its headers
35
36
  def open(host:, port:, path:, last_event_id: nil, headers: {}, timeout: nil)
36
- sock = TCPSocket.new(host, port, connect_timeout: timeout || CONNECT_TIMEOUT)
37
+ begin
38
+ sock = TCPSocket.new(host, port, connect_timeout: timeout || CONNECT_TIMEOUT)
39
+ rescue SystemCallError, SocketError, IOError
40
+ return nil
41
+ end
42
+
37
43
  sock.write(request(host: host, port: port, path: path, last_event_id: last_event_id, headers: headers))
38
44
  deadline = timeout && (Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout)
39
45
  status = header_line(sock, deadline).to_s[STATUS_LINE, 1].to_i
@@ -47,7 +53,7 @@ module Samagotchi
47
53
  [sock, status, received]
48
54
  rescue SystemCallError, SocketError, IOError
49
55
  sock&.close rescue nil
50
- nil
56
+ raise
51
57
  end
52
58
 
53
59
  # The request's bytes.
@@ -8,6 +8,7 @@ require_relative "worker_sidecar"
8
8
  require_relative "bridge_secret"
9
9
  require_relative "delivery"
10
10
  require_relative "chi_invocation"
11
+ require_relative "bridge_client/stream_open"
11
12
  require_relative "bridge_client/sse_parser"
12
13
  require_relative "bridge_client/event_stream"
13
14
  require_relative "bridge_client/stream_request"
@@ -15,8 +16,8 @@ require_relative "bridge_client/stream_request"
15
16
  module Samagotchi
16
17
  # Client side of a session worker's Bridge: the 127.0.0.1 HTTP + SSE server
17
18
  # each SessionManager worker runs for its Engine (see Bridge). Discovery goes
18
- # through the worker's `bridge.json` sidecar (WorkerSidecar) in the session
19
- # directory.
19
+ # through WorkerLink (the worker's `bridge.json` sidecar, WorkerSidecar, in
20
+ # the session directory); .for_sidecar makes the client.
20
21
  #
21
22
  # Requests are raw one-shot HTTP/1.1 over a TCPSocket (`Connection: close`),
22
23
  # exactly what the Bridge's minimal server expects.
@@ -27,7 +28,7 @@ module Samagotchi
27
28
  # The request header that carries the Bridge's secret (Bridge#secret_ok?).
28
29
  SECRET_HEADER = BridgeSecret::HEADER
29
30
  STREAM_CONNECT_ATTEMPTS = 3
30
- # Seconds #stream waits for the Bridge's response headers, per attempt:
31
+ # Seconds #open_stream waits for the Bridge's response headers:
31
32
  # a worker that took the connection and froze must not hold the web's
32
33
  # proxy thread (as EventStream::HEADER_TIMEOUT for the TUI's).
33
34
  STREAM_HEADER_TIMEOUT = 5.0
@@ -49,7 +50,7 @@ module Samagotchi
49
50
  Response = Struct.new(:status, :body, keyword_init: true) do
50
51
  def ok? = status == 200
51
52
 
52
- # The Bridge refused it for want of its secret (BridgeSecret::REFUSED says what to do).
53
+ # The Bridge refused it for want of its secret (BridgeSecret.refused says what to do).
53
54
  def secret_refused? = BridgeSecret.refused?(status, body)
54
55
 
55
56
  # @return [Hash, nil] the body parsed as JSON, or nil when it is not
@@ -75,14 +76,6 @@ module Samagotchi
75
76
  "this session's worker runs an older chi and can't #{cant}; restart it: #{restart} (its turns still work)"
76
77
  end
77
78
 
78
- # The live Bridge's sidecar in +session_dir+, or nil. A sidecar whose
79
- # port refuses a quick connect is stale (its worker died) and is removed.
80
- # @param session_dir [String]
81
- # @return [WorkerSidecar, nil]
82
- def self.live_sidecar(session_dir, host: HOST)
83
- WorkerSidecar.live(session_dir, unlink: true, host: host)
84
- end
85
-
86
79
  # The one way a client is made for a worker: its port and its secret
87
80
  # come from the same sidecar read, so a respawned worker (a new port, a
88
81
  # new secret) is reached by a client made after it, never by hand.
@@ -94,24 +87,11 @@ module Samagotchi
94
87
  new(session_id: session_id, port: sidecar.port, secret: sidecar.secret, host: host, read_timeout: read_timeout)
95
88
  end
96
89
 
97
- # @return [BridgeClient, nil] a client for the live Bridge in +session_dir+
98
- def self.discover(session_id, session_dir:, host: HOST, read_timeout: READ_TIMEOUT)
99
- for_sidecar(session_id, live_sidecar(session_dir, host: host), host: host, read_timeout: read_timeout)
100
- end
101
-
102
90
  # How often a wait for a new worker's Bridge looks: a missing sidecar is
103
91
  # one stat, and a 0.1 s step cost a create up to 0.1 s (the web's
104
92
  # create landed on 0.53/0.63/0.73 s).
105
93
  SPAWN_POLL_INTERVAL = 0.02
106
94
 
107
- # Wait for a freshly spawned worker to publish its Bridge.
108
- # @param timeout [Float] seconds
109
- # @return [BridgeClient, nil] nil when no live sidecar appeared in time
110
- def self.wait_for(session_id, session_dir:, timeout:, host: HOST)
111
- sidecar = poll(timeout, interval: SPAWN_POLL_INTERVAL) { live_sidecar(session_dir, host: host) }
112
- for_sidecar(session_id, sidecar, host: host)
113
- end
114
-
115
95
  # Call the block every +interval+ seconds until it returns a truthy value
116
96
  # or +timeout+ seconds have passed (it is always called at least once).
117
97
  # @return [Object, nil] the block's first truthy value
@@ -314,22 +294,26 @@ module Samagotchi
314
294
  # @return [Array(Integer, Object)] the status and the parsed JSON body
315
295
  # (nil when it isn't JSON); [nil, nil] when no reply came
316
296
  def get(path)
297
+ response = get_response(path)
298
+ [response.status, response.json]
299
+ rescue StandardError
300
+ [nil, nil]
301
+ end
302
+
303
+ # GET /session/:id/<path>, preserving the raw response and raising on I/O.
304
+ # @return [Response]
305
+ def get_response(path)
317
306
  sock = TCPSocket.new(@host, @port)
318
307
  sock.write("GET /session/#{@session_id}/#{path} HTTP/1.1\r\nHost: #{@host}:#{@port}\r\n#{secret_line}Connection: close\r\n\r\n")
319
308
  response = read_reply(sock, path)
320
- sock.close rescue nil
321
309
  # A 500 whose text has "200" in it is not a 200.
322
310
  status = response && response[STATUS_LINE, 1]
323
- return [nil, nil] unless status
311
+ raise IOError, "bridge #{path}: invalid response status" unless status
324
312
 
325
313
  body = response.split("\r\n\r\n", 2)[1] || ""
326
- [status.to_i, begin
327
- JSON.parse(body)
328
- rescue JSON::ParserError
329
- nil
330
- end]
331
- rescue StandardError
332
- [nil, nil]
314
+ Response.new(status: status.to_i, body: body)
315
+ ensure
316
+ sock&.close rescue nil
333
317
  end
334
318
 
335
319
  # Monotonic SSE cursor of the live Engine, from GET /session/:id/state.
@@ -342,46 +326,49 @@ module Samagotchi
342
326
  nil
343
327
  end
344
328
 
345
- # GET /session/:id/stream: yield the raw SSE body bytes as they arrive
346
- # until the Bridge closes the stream.
347
- # @param query [String] "" or "?from_seq=N"
348
- # @param last_event_id [String, nil] reconnect cursor; the Bridge prefers it
349
- # over ?from_seq
350
- # @param running [#call, nil] asked before every read, and every
351
- # STREAM_POLL seconds of silence: the stream ends once it returns
352
- # false. A quiet stream is never cut otherwise.
329
+ # Open GET /session/:id/stream and classify the response before the
330
+ # caller answers its own request.
353
331
  # @param header_timeout [Float] see STREAM_HEADER_TIMEOUT
354
- def stream(query: "", last_event_id: nil, running: nil, header_timeout: STREAM_HEADER_TIMEOUT)
355
- sock = nil
356
- # Absorb the probe→connect race around a resumed worker's bridge:
357
- # the sidecar probe can succeed a moment before the worker dies (or
358
- # the bridge socket briefly refuses). A few quick bounded retries
359
- # avoid the silent empty-200 that EventSource would otherwise keep
360
- # re-opening.
361
- status = nil
362
- STREAM_CONNECT_ATTEMPTS.times do |attempt|
363
- sock, status = connect_stream(query: query, last_event_id: last_event_id, timeout: header_timeout)
364
- break if sock
365
-
366
- sleep(0.15 * (attempt + 1))
367
- end
368
- return unless sock
369
-
370
- # A refusal (a 403 such as bridge_secret) is no event stream: its JSON
371
- # isn't passed on as one.
372
- if status == 403
373
- sock.close rescue nil
374
- return
332
+ # @param attempts [Integer] retries for connection failures only
333
+ # @yield [sock] called after response headers arrive, before classifying a
334
+ # refusal; EventStream uses it to publish the socket for #close
335
+ # @return [StreamOpen::Opened, StreamOpen::Refused, StreamOpen::Unreachable]
336
+ def open_stream(query: "", last_event_id: nil, header_timeout: STREAM_HEADER_TIMEOUT,
337
+ attempts: STREAM_CONNECT_ATTEMPTS)
338
+ count = 0
339
+ while count < attempts
340
+ count += 1
341
+ connection = connect_stream(query: query, last_event_id: last_event_id, timeout: header_timeout)
342
+ if connection
343
+ sock, status, headers = connection
344
+ unless !block_given? || yield(sock)
345
+ sock.close rescue nil
346
+ return StreamOpen::Unreachable.new(attempts: count)
347
+ end
348
+ return StreamOpen.classify(sock, status, headers)
349
+ end
350
+ sleep(0.15 * count) if count < attempts
375
351
  end
352
+ StreamOpen::Unreachable.new(attempts: count)
353
+ rescue Errno::ETIMEDOUT
354
+ StreamOpen::Unreachable.new(attempts: [count, 1].max)
355
+ rescue SystemCallError, SocketError, IOError
356
+ StreamOpen::Unreachable.new(attempts: [count, 1].max)
357
+ end
376
358
 
359
+ # Read a successful stream opened by #open_stream.
360
+ # @param opened [StreamOpen::Opened]
361
+ # @param running [#call, nil] checked before every read and every
362
+ # STREAM_POLL seconds of silence
363
+ def read_stream(opened, running: nil)
364
+ sock = opened.sock
377
365
  begin
378
366
  loop do
379
367
  if running
380
368
  break unless running.call
381
369
  next unless sock.wait_readable(STREAM_POLL)
382
370
  end
383
- chunk = sock.readpartial(4096)
384
- yield chunk
371
+ yield sock.readpartial(4096)
385
372
  rescue IOError, Errno::ECONNRESET, Errno::ECONNREFUSED # EOFError is an IOError
386
373
  break
387
374
  end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "json"
4
+ require_relative "chi_invocation"
4
5
 
5
6
  module Samagotchi
6
7
  # A session Bridge's secret, as both sides name it: the request header
@@ -12,16 +13,17 @@ module Samagotchi
12
13
  HEADER = "X-Chi-Bridge"
13
14
  # The 403's error.
14
15
  ERROR = "bridge_secret"
15
- # What a request without this Bridge's secret, or with another one, is
16
- # told, and what a client shows when it gets that: the asker comes from
17
- # a chi that reads no secret, a chi web, an attached terminal or a
18
- # session worker (a delegate's parent) started before chi was updated.
19
- REFUSED = "the request didn't name this session worker's secret: a chi web, an attached terminal or a session " \
20
- "worker started before chi was updated can't reach it; restart chi web, re-attach terminals " \
21
- "(chi --attach ID) and restart the older session workers (chi sessions restart ID...)"
22
16
 
23
17
  module_function
24
18
 
19
+ # What every client shows when a worker refuses its secret.
20
+ def refused(session_id, via: ChiInvocation::HERE)
21
+ web = via.remote? ? via.web : "chi web"
22
+ "session #{session_id}'s worker refused this chi's request (it didn't name its secret): a chi web or " \
23
+ "terminal started before chi was updated can't reach it; restart #{web}, re-attach with " \
24
+ "#{via.attach(session_id)}, or restart the worker with #{via.command("sessions restart #{session_id}")}"
25
+ end
26
+
25
27
  # Whether a Bridge reply (+status+, raw +body+) is this refusal.
26
28
  def refused?(status, body)
27
29
  return false unless status.to_i == 403
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "prism"
3
4
  require "rbconfig"
4
5
  require "stringio"
5
6
  require "tmpdir"
@@ -214,8 +215,9 @@ module Samagotchi
214
215
  floor = lower_bound(manifest.requires_chi)
215
216
  return unless floor && File.file?(path(manifest.plugin[:file]))
216
217
 
217
- calls = File.read(path(manifest.plugin[:file])).scan(API_CALL).flat_map { |call| [call, call.split(".").first(2).join(".")] }
218
- .uniq.select { |call| Plugin::Api::SINCE.key?(call) }
218
+ source = File.read(path(manifest.plugin[:file]))
219
+ calls = source.scan(API_CALL).flat_map { |call| [call, call.split(".").first(2).join(".")] }
220
+ calls = (calls + keyword_calls(source)).uniq.select { |call| Plugin::Api::SINCE.key?(call) }
219
221
  call = calls.max_by { |c| Gem::Version.new(Plugin::Api::SINCE[c]) } or return
220
222
  version = Plugin::Api::SINCE[call]
221
223
  return unless Gem::Version.new(version) > floor
@@ -226,6 +228,29 @@ module Samagotchi
226
228
  fix: "requires_chi: \">= #{version}\"")
227
229
  end
228
230
 
231
+ # Each API call's keywords in +source+, as "chi.toolkit(groups:)": the
232
+ # call's receiver as written, its name and the keyword (Prism sees
233
+ # them with or without parens, over lines). Nothing when it doesn't parse.
234
+ def keyword_calls(source)
235
+ result = Prism.parse(source)
236
+ return [] unless result.success?
237
+
238
+ found = []
239
+ nodes = [result.value]
240
+ while (node = nodes.pop)
241
+ nodes.concat(node.compact_child_nodes)
242
+ next unless node.is_a?(Prism::CallNode) && node.receiver && node.arguments
243
+
244
+ call = "#{node.receiver.slice}.#{node.name}"
245
+ node.arguments.arguments.grep(Prism::KeywordHashNode).each do |hash|
246
+ hash.elements.each do |pair|
247
+ found << "#{call}(#{pair.key.unescaped}:)" if pair.is_a?(Prism::AssocNode) && pair.key.is_a?(Prism::SymbolNode)
248
+ end
249
+ end
250
+ end
251
+ found
252
+ end
253
+
229
254
  def lower_bound(requirement)
230
255
  requirement.to_s.split(",").filter_map do |part|
231
256
  version = part.strip[LOWER_BOUND, 1]
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: claude-peer
3
- version: 0.4.4
3
+ version: 0.4.5
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:106d5b44020602fcd2d8065da30d10e1043e30c1b31ea82b4f27f7f79ada6592
15
+ pair: sha256:d8089ec422a816367d8c41393235223e06b21cbc0ae1e37b6fc22e94ea9a1497
16
16
  chi-peer-SKILL.md: sha256:b9366f8f4aee9c54d209368ce421eab7b971c03c75efab0415d1988fd40f9e60
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)
@@ -26,17 +26,20 @@ while [ $# -gt 0 ]; do
26
26
  case "$1" in
27
27
  --inbox-socket)
28
28
  [ $# -ge 2 ] || usage
29
- inbox=$2 shift 2
29
+ inbox=$2
30
+ shift 2
30
31
  [ -n "$inbox" ] || usage
31
32
  ;;
32
33
  --inbox-socket=*)
33
- inbox=${1#--inbox-socket=} shift
34
+ inbox=${1#--inbox-socket=}
35
+ shift
34
36
  [ -n "$inbox" ] || usage
35
37
  ;;
36
38
  -h | --help) usage ;;
37
39
  *)
38
40
  [ -z "$target" ] || usage
39
- target=$1 shift
41
+ target=$1
42
+ shift
40
43
  ;;
41
44
  esac
42
45
  done
@@ -84,10 +87,21 @@ case "$target" in
84
87
  *) id=$target ;;
85
88
  esac
86
89
 
87
- # A JSON string: backslashes, quotes, tabs and newlines escaped.
90
+ # A JSON string: backslashes and quotes escaped, newlines, tabs and
91
+ # carriage returns as \n, \t, \r, any other control character as \u00XX.
88
92
  json() {
89
- printf '%s' "$1" | sed -e 's/\\/\\\\/g' -e 's/"/\\"/g' -e 's/ /\\t/g' |
90
- awk 'BEGIN { ORS = ""; printf "\"" } NR > 1 { print "\\n" } { print } END { printf "\"" }'
93
+ printf '%s' "$1" | awk '
94
+ BEGIN {
95
+ ORS = ""; printf "\""
96
+ for (i = 1; i < 32; i++) esc[sprintf("%c", i)] = sprintf("\\u%04x", i)
97
+ esc["\t"] = "\\t"; esc["\r"] = "\\r"; esc["\\"] = "\\\\"; esc["\""] = "\\\""
98
+ }
99
+ NR > 1 { print "\\n" }
100
+ {
101
+ n = length($0)
102
+ for (i = 1; i <= n; i++) { c = substr($0, i, 1); print ((c in esc) ? esc[c] : c) }
103
+ }
104
+ END { printf "\"" }'
91
105
  }
92
106
 
93
107
  # Attaches a pushed source, unless the session has one of that name already.
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: codex-peer
3
- version: 0.3.3
3
+ version: 0.3.4
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:0830dcb856debea0e4a2a0eb0da8a73b9e9ac2444da957d2928ec734280186a1
15
+ pair: sha256:a61c3d311ce3f3e5386e90f0f867f649a39763a5883877e731e43d84dd9c85aa
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)
@@ -93,10 +93,21 @@ MSG
93
93
  exit 1
94
94
  }
95
95
 
96
- # A JSON string: backslashes, quotes, tabs and newlines escaped.
96
+ # A JSON string: backslashes and quotes escaped, newlines, tabs and
97
+ # carriage returns as \n, \t, \r, any other control character as \u00XX.
97
98
  json() {
98
- printf '%s' "$1" | sed -e 's/\\/\\\\/g' -e 's/"/\\"/g' -e 's/ /\\t/g' |
99
- awk 'BEGIN { ORS = ""; printf "\"" } NR > 1 { print "\\n" } { print } END { printf "\"" }'
99
+ printf '%s' "$1" | awk '
100
+ BEGIN {
101
+ ORS = ""; printf "\""
102
+ for (i = 1; i < 32; i++) esc[sprintf("%c", i)] = sprintf("\\u%04x", i)
103
+ esc["\t"] = "\\t"; esc["\r"] = "\\r"; esc["\\"] = "\\\\"; esc["\""] = "\\\""
104
+ }
105
+ NR > 1 { print "\\n" }
106
+ {
107
+ n = length($0)
108
+ for (i = 1; i <= n; i++) { c = substr($0, i, 1); print ((c in esc) ? esc[c] : c) }
109
+ }
110
+ END { printf "\"" }'
100
111
  }
101
112
 
102
113
  # Attaches a pushed source, unless the session has one of that name already.
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: github-pr
3
- version: 0.5.0
3
+ version: 0.5.1
4
4
  scope: system
5
5
  description: Attaches the branch's open GitHub pull request to a session as context (and any PR URL with chi context add or the web's "+ URL"); the agent gets a note when reviews, comments, checks or its state change; in the web, a path:line in an answer links to that line of the PR; the auto_attach setting can offer the PR with a card first, or attach nothing
6
6
  trust_level: reviewed
@@ -11,7 +11,7 @@ needs:
11
11
  hint: brew install gh, then gh auth login
12
12
  plugin:
13
13
  file: plugin.rb
14
- sha256: sha256:b9e3d7a837ecaa2b0e53f3d62d2ccedd532f4d1b5d6faefd5adc9570cb3c4efe
14
+ sha256: sha256:cfadb7668528302b3631681e570899230b6878c80ccbc87dfb16c80d43dead7b
15
15
  scripts:
16
16
  pr_context.rb: sha256:a883d2780e89c68c0d7b6d5e0fcb22c273f17bb167e79064e0e99db6bd2ee6f0
17
17
  context_providers:
@@ -649,12 +649,8 @@ class LineLinker
649
649
  out << head[pos..] << text[MAX_SCAN..].to_s
650
650
  end
651
651
 
652
- # The URL for +path+'s lines +from+..+to+ (+to+ nil: one line), or nil:
653
- # no PR has the file, or more than one does.
654
- def url_for(path, from, to = nil) = line_for(path, from, to)&.fetch(:url)
655
-
656
- # {url:, diff:, path:, line:, end_line:} for +path+'s lines (see #url_for),
657
- # or nil.
652
+ # {url:, diff:, path:, line:, end_line:} for +path+'s lines +from+..+to+
653
+ # (+to+ nil: one line), or nil: no PR has the file, or more than one does.
658
654
  def line_for(path, from, to = nil)
659
655
  found = @prs.filter_map do |pr, data|
660
656
  file = resolve(data.files, path)
@@ -1,11 +1,11 @@
1
1
  ---
2
2
  name: samagotchi-system
3
- version: 0.121.0
3
+ version: 0.122.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:
7
7
  identity.md: sha256:29c2f7df9f6c5acbc5ec17000a4490741e55e426af15ca0d81c8706edd9bd027
8
8
  self_map.md: sha256:df3f8da539a9f5812f79e089c2c8b9894da6124a89654f292160046a6bb060da
9
9
  config_modification_protocol.md: sha256:ff4efccf2bdde453fc4e6741df8c18f78da78e353f6b1a5a64d0e7a631aea258
10
- memory_guide.md: sha256:dfb3982cf281bf7920d018ea65b68b5755f86d8bd1e03bab9db42393a94ec7e0
10
+ memory_guide.md: sha256:3b9ff779ee962f1307d56937c826d5e581923114f3f39455b779ca618b363cd7
11
11
  delegated.md: sha256:a0d6ddcd4a8be06526d964898611775fd251cfd9d145a9d16afaccaa503597bd
@@ -86,6 +86,19 @@ Shape (plain Markdown, no frontmatter):
86
86
  - **Updating**: when a step turned out different, update the skill in the same turn: `edit` those steps in its file (`<scope dir>/skill_<name>.md`), or rewrite it with `memory_write`; keep the rest as it was, add a dated Changelog line. No confirmation needed.
87
87
  - **The `skills` bundle** (`chi bundle install skills`, optional) adds `/skill save [name] [--system]`, `/skill list`, `/skill show <name>`, `/skill diff <name> [N]`; it keeps older versions and shows a short diff line after each update.
88
88
 
89
+ ## Toolkit memories
90
+
91
+ A memory named `toolkit_<name>` defines a kit named `<name>`. Its body is one fenced YAML block with `base`, `add`,
92
+ `remove`, `groups` and a required `description`; never include `prompt:`. Only registered tools are declared, and
93
+ the built-in names (`full`, `lean`, `child`, `review`) are reserved. Project kits take precedence over system kits;
94
+ config and bundle kits take precedence over both. `/toolkit` lists a memory kit's source, and `--mute
95
+ toolkit_<name>` skips it for the session.
96
+
97
+ Write a kit when the user asks for one, when they use `/toolkit add` to choose the same tools for the same kind of
98
+ work twice, or when `chi toolkit report` shows a kit repeatedly short of a tool for one kind of prompt. A tweak to a
99
+ config or bundle kit is a new memory kit based on it (`base: staging`, for example), never a `config.yml` edit.
100
+ Writing or updating a toolkit memory is free like writing a skill; say so in the reply.
101
+
89
102
  ## Memory Bundles — shareable packs
90
103
 
91
104
  Bundles are versioned directories/zips/tar.gz/git URLs with a `manifest.yml` and any of: memories (`*.md`), `hooks/*.rb` (bundle hooks, `docs/hooks.md`), `guardrails/*.yml` (rules, `docs/guardrails.md`), a `plugin.rb` (commands, tools, hooks, services; `docs/plugins.md`). They are shareable and installable.
@@ -6,7 +6,7 @@ require "time"
6
6
  require_relative "atomic_file"
7
7
  require_relative "session"
8
8
  require_relative "session_inbox"
9
- require_relative "worker_sidecar"
9
+ require_relative "worker_link"
10
10
  require_relative "log"
11
11
  require_relative "tools/delegate_cursor"
12
12
 
@@ -93,13 +93,11 @@ module Samagotchi
93
93
  # installed (SessionManager.worker_command): it moves.
94
94
  # @return [String, nil]
95
95
  def unmovable(child_id, state_dir:)
96
- owner = SessionManager.session_owner(child_id, state_dir: state_dir) or return nil
97
- return "open in a chi REPL; close it there" if owner.tui?
96
+ link = WorkerLink.resolve(Session.session_dir(child_id, state_dir: state_dir), unlink: false)
97
+ return "open in a chi REPL; close it there" if link.owner&.tui?
98
+ return nil if !link.live? || link.features.include?(FEATURE)
98
99
 
99
- sidecar = WorkerSidecar.live(Session.session_dir(child_id, state_dir: state_dir), unlink: false)
100
- return nil if sidecar.nil? || sidecar.features.include?(FEATURE)
101
-
102
- "older chi worker#{" #{sidecar.version}" if sidecar.version}; restart it"
100
+ "older chi worker#{" #{link.version}" if link.version}; restart it"
103
101
  end
104
102
 
105
103
  # Move +ids+ (direct children of +from+) to +to+: the intent, the
@@ -5,6 +5,7 @@ require_relative "session"
5
5
  require_relative "archive_store"
6
6
  require_relative "git_head"
7
7
  require_relative "reply_wait"
8
+ require_relative "worker_link"
8
9
  require_relative "tools/delegate"
9
10
  require_relative "tools/delegate_cursor"
10
11
 
@@ -106,7 +107,7 @@ module Samagotchi
106
107
  # The keys of a children_of row that state_of and child read, for a
107
108
  # session counts loaded itself.
108
109
  def summary_of(session, state_dir:)
109
- live = SessionManager.worker_live?(session.id, state_dir: state_dir)
110
+ live = WorkerLink.resolve(Session.session_dir(session.id, state_dir: state_dir), unlink: false).worker_owned?
110
111
  { live: live, busy: live && session.status == Session::STATUS_RUNNING, status: session.status,
111
112
  updated_at: session.updated_at, waiting: session.waiting_question(live: live)&.dig(:kind),
112
113
  preview: session.last_prompt.to_s }
@@ -249,6 +249,11 @@ module Samagotchi
249
249
  # delegate child's. toolkit.kits (the user's own) is read by Toolkit.kits.
250
250
  Entry.new(key: "toolkit.default", yaml_path: %w[toolkit default], type: :string, default: nil, expose: %i[env config], lifecycle: :session_creation),
251
251
  Entry.new(key: "toolkit.child", yaml_path: %w[toolkit child], type: :string, default: nil, expose: %i[env config], lifecycle: :session_creation),
252
+ # Toolkit suggestion (ToolkitSuggest): off, audit, or on; on currently behaves as audit and never changes a session.
253
+ Entry.new(key: "toolkit.suggest.mode", yaml_path: %w[toolkit suggest mode], type: :enum, default: "off", expose: %i[config], enum_values: %w[off audit on], lifecycle: :worker_start),
254
+ Entry.new(key: "toolkit.suggest.threshold", yaml_path: %w[toolkit suggest threshold], type: :float, default: 0.5, expose: %i[config], lifecycle: :worker_start),
255
+ Entry.new(key: "toolkit.suggest.defer_turns", yaml_path: %w[toolkit suggest defer_turns], type: :integer, default: 3, expose: %i[config], lifecycle: :worker_start),
256
+ Entry.new(key: "toolkit.suggest.defer_tokens", yaml_path: %w[toolkit suggest defer_tokens], type: :integer, default: 5000, expose: %i[config], lifecycle: :worker_start),
252
257
 
253
258
  Entry.new(key: "default.max_tokens", yaml_path: %w[default max_tokens], type: :integer, default: nil, expose: %i[env config cli], lifecycle: :next_matching_use),
254
259
  Entry.new(key: "max_tool_output_chars", yaml_path: %w[max_tool_output_chars], type: :integer, default: 10_000, expose: %i[env config cli], lifecycle: :next_matching_use),