samagotchi 0.120.0 → 0.121.1

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 (120) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +116 -1
  3. data/bin/chi +3 -2
  4. data/docs/architecture.md +36 -11
  5. data/docs/bundle-authoring.md +2 -1
  6. data/docs/cli.md +10 -4
  7. data/docs/configuration.md +34 -11
  8. data/docs/guardrails.md +48 -19
  9. data/docs/internals/anthropic.md +1 -1
  10. data/docs/internals/background-tasks.md +1 -1
  11. data/docs/internals/participants.md +6 -2
  12. data/docs/internals/prompt-caching.md +7 -6
  13. data/docs/internals/remote-sessions.md +15 -11
  14. data/docs/internals/web-push.md +22 -11
  15. data/docs/plugins.md +28 -8
  16. data/docs/sessions.md +15 -0
  17. data/docs/testing.md +44 -2
  18. data/lib/samagotchi/answer_command.rb +1 -1
  19. data/lib/samagotchi/bridge.rb +8 -8
  20. data/lib/samagotchi/bridge_client/event_stream.rb +11 -27
  21. data/lib/samagotchi/bridge_client/stream_open.rb +79 -0
  22. data/lib/samagotchi/bridge_client/stream_request.rb +8 -2
  23. data/lib/samagotchi/bridge_client.rb +52 -65
  24. data/lib/samagotchi/bridge_secret.rb +9 -7
  25. data/lib/samagotchi/bundle_command/check.rb +27 -2
  26. data/lib/samagotchi/bundles/claude-peer/manifest.yml +2 -2
  27. data/lib/samagotchi/bundles/claude-peer/scripts/pair +21 -7
  28. data/lib/samagotchi/bundles/codex-peer/manifest.yml +2 -2
  29. data/lib/samagotchi/bundles/codex-peer/scripts/pair +14 -3
  30. data/lib/samagotchi/bundles/dev/manifest.yml +2 -2
  31. data/lib/samagotchi/bundles/github-pr/manifest.yml +2 -2
  32. data/lib/samagotchi/bundles/github-pr/plugin.rb +2 -6
  33. data/lib/samagotchi/bundles/mcp/manifest.yml +3 -3
  34. data/lib/samagotchi/bundles/mcp/plugin.rb +5 -1
  35. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  36. data/lib/samagotchi/child_move.rb +5 -7
  37. data/lib/samagotchi/children_status.rb +2 -1
  38. data/lib/samagotchi/engine.rb +1 -0
  39. data/lib/samagotchi/guardrail_wiring.rb +19 -2
  40. data/lib/samagotchi/guardrails/parent_approvals.rb +9 -0
  41. data/lib/samagotchi/guardrails_command.rb +41 -6
  42. data/lib/samagotchi/image_store.rb +6 -3
  43. data/lib/samagotchi/interrupted_turn.rb +6 -3
  44. data/lib/samagotchi/kernel_loop.rb +5 -0
  45. data/lib/samagotchi/live_versions.rb +6 -7
  46. data/lib/samagotchi/memory_bundle/profile.rb +1 -1
  47. data/lib/samagotchi/memory_bundle/uninstaller.rb +6 -2
  48. data/lib/samagotchi/plugin/api.rb +14 -8
  49. data/lib/samagotchi/plugin/sessions.rb +13 -4
  50. data/lib/samagotchi/push_command.rb +1 -1
  51. data/lib/samagotchi/relay_verifier.rb +2 -3
  52. data/lib/samagotchi/relay_watcher.rb +2 -2
  53. data/lib/samagotchi/remote_attach_command.rb +1 -1
  54. data/lib/samagotchi/reply_wait/poll.rb +8 -2
  55. data/lib/samagotchi/reply_wait.rb +4 -3
  56. data/lib/samagotchi/review_command.rb +1 -1
  57. data/lib/samagotchi/run_options.rb +1 -0
  58. data/lib/samagotchi/session.rb +4 -0
  59. data/lib/samagotchi/session_access/local.rb +18 -8
  60. data/lib/samagotchi/session_access/remote.rb +5 -0
  61. data/lib/samagotchi/session_access/web_bridge_client.rb +31 -19
  62. data/lib/samagotchi/session_access.rb +21 -9
  63. data/lib/samagotchi/session_continue.rb +2 -3
  64. data/lib/samagotchi/session_manager.rb +118 -28
  65. data/lib/samagotchi/sessions_command.rb +10 -5
  66. data/lib/samagotchi/system_one/client.rb +6 -1
  67. data/lib/samagotchi/system_one/fake_backend.rb +0 -2
  68. data/lib/samagotchi/system_one/log.rb +21 -6
  69. data/lib/samagotchi/system_one/self_protect.rb +39 -43
  70. data/lib/samagotchi/terminal_ui/attach_launcher.rb +8 -3
  71. data/lib/samagotchi/terminal_ui/attached_loop.rb +4 -2
  72. data/lib/samagotchi/terminal_ui/local_link.rb +28 -19
  73. data/lib/samagotchi/terminal_ui/question_slot.rb +19 -8
  74. data/lib/samagotchi/tool_declarations.rb +1 -1
  75. data/lib/samagotchi/tool_runner.rb +37 -1
  76. data/lib/samagotchi/toolkit.rb +117 -27
  77. data/lib/samagotchi/toolkit_binding.rb +32 -0
  78. data/lib/samagotchi/toolkit_command.rb +23 -0
  79. data/lib/samagotchi/tools/deferred_tools.rb +10 -2
  80. data/lib/samagotchi/tools/delegate.rb +1 -1
  81. data/lib/samagotchi/tools/delegate_relay.rb +9 -7
  82. data/lib/samagotchi/tools/registry.rb +20 -6
  83. data/lib/samagotchi/tools/tool_index.rb +103 -20
  84. data/lib/samagotchi/turn_checkpoint.rb +15 -9
  85. data/lib/samagotchi/version.rb +1 -1
  86. data/lib/samagotchi/web/app.rb +123 -65
  87. data/lib/samagotchi/web/participant_access.rb +26 -7
  88. data/lib/samagotchi/web/participant_review.rb +11 -6
  89. data/lib/samagotchi/web/participant_view.rb +7 -3
  90. data/lib/samagotchi/web/participants_admin.rb +13 -0
  91. data/lib/samagotchi/web/public/app.js +1 -1
  92. data/lib/samagotchi/web/public/forms.css +12 -0
  93. data/lib/samagotchi/web/public/index.html +7 -5
  94. data/lib/samagotchi/web/public/level_chip.js +2 -2
  95. data/lib/samagotchi/web/public/llm_context_chip.js +4 -4
  96. data/lib/samagotchi/web/public/notifications.js +21 -1
  97. data/lib/samagotchi/web/public/participant.html +4 -2
  98. data/lib/samagotchi/web/public/participant.js +34 -11
  99. data/lib/samagotchi/web/public/participants_panel.js +49 -14
  100. data/lib/samagotchi/web/public/push.js +4 -3
  101. data/lib/samagotchi/web/public/push_devices.js +17 -3
  102. data/lib/samagotchi/web/public/question_card.js +15 -1
  103. data/lib/samagotchi/web/public/question_cards.js +11 -2
  104. data/lib/samagotchi/web/public/review_history.js +24 -4
  105. data/lib/samagotchi/web/public/review_start.js +1 -3
  106. data/lib/samagotchi/web/public/session_actions.js +14 -2
  107. data/lib/samagotchi/web/public/storage.js +16 -0
  108. data/lib/samagotchi/web/public/toolkit_chip.js +3 -0
  109. data/lib/samagotchi/web/push/client.rb +5 -0
  110. data/lib/samagotchi/web/push/store.rb +16 -3
  111. data/lib/samagotchi/web/push_routes.rb +3 -2
  112. data/lib/samagotchi/web/session_access_routes.rb +25 -17
  113. data/lib/samagotchi/web/session_create.rb +4 -4
  114. data/lib/samagotchi/web/session_hub.rb +6 -4
  115. data/lib/samagotchi/web/session_summary.rb +8 -16
  116. data/lib/samagotchi/web/tunnel.rb +10 -1
  117. data/lib/samagotchi/worker.rb +1 -1
  118. data/lib/samagotchi/worker_link.rb +213 -0
  119. data/lib/samagotchi/worker_sidecar.rb +15 -37
  120. metadata +4 -1
@@ -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.3
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:bd850e8091098e249ac5b9b04a95a4d8a0dd8501cf9d58639a47d4f3bce1d757
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.
@@ -147,6 +161,6 @@ attach claude-outbox --why "Claude's answers to what you sent it (claude-peer)"
147
161
  if [ -z "$inbox" ]; then
148
162
  echo "paired with chi $id (socket $sock)"
149
163
  else
150
- echo "paired with chi $id (socket $inbox on chi's machine, forwarded from $sock)"
164
+ echo "paired with chi $id (socket $inbox on chi's machine, forwarded to this Claude's $sock)"
151
165
  fi
152
166
  $chi context ls "$id"
@@ -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,10 +1,10 @@
1
1
  ---
2
2
  name: dev
3
- version: 0.7.2
3
+ version: 0.7.3
4
4
  scope: system
5
5
  description: "Developer tools"
6
6
  trust_level: reviewed
7
- requires_chi: ">= 0.107.0"
7
+ requires_chi: ">= 0.121.0"
8
8
  includes:
9
9
  - known-names
10
10
  - mcp
@@ -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,12 +1,12 @@
1
1
  ---
2
2
  name: mcp
3
- version: 0.7.1
3
+ version: 0.7.2
4
4
  scope: system
5
5
  description: "Tools from MCP servers (stdio): each server in config.yml bundles: mcp: servers: runs for the session; their tools go into chi's tool index: the model searches them with find_tools and calls one with call_tool (<server>/<tool>); /mcp lists them"
6
6
  trust_level: reviewed
7
7
  files: {}
8
8
  plugin:
9
9
  file: plugin.rb
10
- sha256: sha256:67b1ed9337236deb366459aaaed4e49f0d3122a0b38f3ff9328babcd332c0182
10
+ sha256: sha256:c4bb4cf99ad43168ea68c25283a153c5dea1e5be123743c8a4c1278c16b5ea90
11
11
  # 0.67.0 is the release with chi.tool_source (the core tool index the bundle publishes into).
12
- requires_chi: ">= 0.67.0"
12
+ requires_chi: ">= 0.121.0"
@@ -354,6 +354,7 @@ class Plugin
354
354
  def register(chi)
355
355
  @chi = chi
356
356
  ctx = chi.ctx
357
+ @ctx = ctx
357
358
  configs = @settings["servers"]
358
359
  configs = {} unless configs.is_a?(Hash)
359
360
  unless configs.empty?
@@ -952,7 +953,10 @@ class Plugin
952
953
  return "No servers. Add them in config.yml under `bundles: mcp: servers:` (docs/plugins.md, The mcp bundle)." if @servers.empty?
953
954
 
954
955
  servers = @servers.map do |server|
955
- head = "**#{server.name}**: #{state_text(server)}"
956
+ marker = if @source && !@source.declared_groups.include?(server.name)
957
+ " (not in toolkit #{@ctx.toolkit})"
958
+ end
959
+ head = "**#{server.name}**: #{state_text(server)}#{marker}"
956
960
  # Sorted: a server lists its tools in its own (often grouped) order.
957
961
  tools = server.tools.sort_by(&:name).map { |t| ["- `#{t.server}/#{t.name}`", mark(t)].compact.join(" ") }
958
962
  tools.empty? ? head : "#{head}\n#{tools.join("\n")}"
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: samagotchi-system
3
- version: 0.120.0
3
+ version: 0.121.1
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:
@@ -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 }
@@ -377,6 +377,7 @@ module Samagotchi
377
377
  # A cut tool output is saved whole in the session's folder.
378
378
  @kernel.session_dir = -> { @session&.id && Session.session_dir(@session.id, state_dir: session_state_dir) }
379
379
  @kernel.guardrail_gate = @guardrail_wiring.gate
380
+ @kernel.participant_turn = -> { Steer.participant_author?(turn_author) }
380
381
  # The turn-end warm-up; the kernel asks it for a slot pin per request.
381
382
  @prompt_warmup = PromptWarmup.new
382
383
  @kernel.warmup = @prompt_warmup
@@ -271,7 +271,14 @@ module Samagotchi
271
271
  # System 1: the ask is judged before the user sees it, and in `on` mode
272
272
  # one that passes every gate is allowed once without reaching them
273
273
  # (docs/guardrails.md, "System 1").
274
- judgment = system_one_judge(payload)
274
+ judgment = begin
275
+ system_one_judge(payload)
276
+ rescue LLM::RequestCancelled
277
+ return turn_stopped(verdict)
278
+ end
279
+ # A turn stopped while the judge was asked opens no card.
280
+ return turn_stopped(verdict) if @cancelled_lookup.call
281
+
275
282
  if judgment && judgment[:auto]
276
283
  verdict.judgment = { id: judgment[:id], p: judgment[:p] }
277
284
  verdict.settle!(:allow, decided_by: "system one")
@@ -286,6 +293,12 @@ module Samagotchi
286
293
 
287
294
  private
288
295
 
296
+ # The deny for a call whose turn was stopped before its ask reached the
297
+ # user (as Guardrails::Gate words one stopped before the vote).
298
+ def turn_stopped(verdict)
299
+ verdict.settle!(:deny, decided_by: "core", note: "The turn was stopped before the user was asked.")
300
+ end
301
+
289
302
  # The judgments log (SystemOne::Log), beside the approvals store; nil
290
303
  # while there is no store dir.
291
304
  def system_one_log
@@ -319,6 +332,8 @@ module Samagotchi
319
332
  entries: entries),
320
333
  questions: { SystemOne::ApprovalQuestion::ID => SystemOne::ApprovalQuestion.question },
321
334
  cancel_controller: @cancel_controller.call)
335
+ rescue LLM::RequestCancelled
336
+ raise
322
337
  rescue StandardError => e
323
338
  # The client already turns every failure it knows into a nil; this is
324
339
  # the unexpected one. Either way the ask goes to the user.
@@ -338,6 +353,8 @@ module Samagotchi
338
353
  latency_ms: latency_ms, decision: decision, origin: "live")
339
354
  # A judgment the log cannot hold is no judgment: the user is asked.
340
355
  { id: id, p: p, auto: auto && !id.nil? }
356
+ rescue LLM::RequestCancelled
357
+ raise
341
358
  rescue StandardError => e
342
359
  # The approval never fails because of System 1.
343
360
  Samagotchi::Log.warn(:guardrails, "system_one_judgment_failed", error: "#{e.class}: #{e.message}")
@@ -408,7 +425,7 @@ module Samagotchi
408
425
  "deny"
409
426
  end
410
427
  end
411
- system_one_log.label(id, label)
428
+ system_one_log.label(id, label, question_id: (answer.id if answer.is_a?(Samagotchi::QuestionAnswer)))
412
429
  rescue StandardError => e
413
430
  Samagotchi::Log.warn(:guardrails, "system_one_label_failed", error: "#{e.class}: #{e.message}")
414
431
  nil
@@ -69,6 +69,15 @@ module Samagotchi
69
69
  # memory-remove rule's ask, which a parent may answer.
70
70
  return false if fetch(facts, :rule).to_s == MEMORY_REMOVE_RULE
71
71
 
72
+ chi_files?(facts)
73
+ end
74
+
75
+ # Whether an approval's paths or command name chi's own dirs (#chi_dirs)
76
+ # or CHI_TEXT, whatever its rule: the part of #protected_facts? that
77
+ # System 1's SystemOne::SelfProtect shares.
78
+ def chi_files?(facts)
79
+ return false unless facts.is_a?(Hash)
80
+
72
81
  dirs = chi_dirs
73
82
  paths = Array(fetch(facts, :paths)).map(&:to_s).reject(&:empty?)
74
83
  return true if paths.any? { |path| dirs.any? { |dir| ProtectedPaths.within?(ProtectedPaths.real(path), dir) } }