samagotchi 0.65.0 → 0.67.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 (155) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +107 -1
  3. data/README.md +2 -1
  4. data/bin/chi +51 -2
  5. data/docs/architecture.md +50 -28
  6. data/docs/cli.md +14 -2
  7. data/docs/configuration.md +101 -16
  8. data/docs/context.md +39 -2
  9. data/docs/desktop.md +90 -22
  10. data/docs/guardrails.md +8 -5
  11. data/docs/hooks.md +2 -2
  12. data/docs/internals/anthropic.md +156 -0
  13. data/docs/internals/background-tasks.md +1 -1
  14. data/docs/internals/bundles.md +2 -0
  15. data/docs/internals/engine.md +75 -30
  16. data/docs/internals/gemma4-contract.md +1 -1
  17. data/docs/internals/llm-context-bench.md +5 -4
  18. data/docs/internals/prompt-caching.md +36 -13
  19. data/docs/memory.md +1 -1
  20. data/docs/plugins.md +289 -25
  21. data/docs/releasing.md +1 -1
  22. data/docs/sessions.md +16 -6
  23. data/docs/sub-agent.md +6 -1
  24. data/docs/testing.md +17 -3
  25. data/lib/samagotchi/bootstrap/probe.rb +4 -3
  26. data/lib/samagotchi/bootstrap_command.rb +13 -3
  27. data/lib/samagotchi/bridge/ring_buffer.rb +0 -5
  28. data/lib/samagotchi/bridge.rb +6 -3
  29. data/lib/samagotchi/bundles/claude-peer/manifest.yml +7 -6
  30. data/lib/samagotchi/bundles/claude-peer/plugin.rb +40 -302
  31. data/lib/samagotchi/bundles/claude-peer/scripts/chi-peer-SKILL.md +8 -3
  32. data/lib/samagotchi/bundles/claude-peer/skill_claude_peer.md +14 -6
  33. data/lib/samagotchi/bundles/codex-peer/manifest.yml +22 -0
  34. data/lib/samagotchi/bundles/codex-peer/plugin.rb +189 -0
  35. data/lib/samagotchi/bundles/codex-peer/scripts/chi-peer-SKILL.md +136 -0
  36. data/lib/samagotchi/bundles/codex-peer/scripts/pair +92 -0
  37. data/lib/samagotchi/bundles/codex-peer/skill_codex_peer.md +76 -0
  38. data/lib/samagotchi/bundles/coordinator/manifest.yml +5 -5
  39. data/lib/samagotchi/bundles/coordinator/plugin.rb +24 -2
  40. data/lib/samagotchi/bundles/dev/manifest.yml +2 -1
  41. data/lib/samagotchi/bundles/mcp/manifest.yml +5 -5
  42. data/lib/samagotchi/bundles/mcp/plugin.rb +82 -294
  43. data/lib/samagotchi/bundles/system/config_modification_protocol.md +7 -3
  44. data/lib/samagotchi/bundles/system/delegated.md +2 -1
  45. data/lib/samagotchi/bundles/system/manifest.yml +5 -5
  46. data/lib/samagotchi/bundles/system/memory_guide.md +1 -1
  47. data/lib/samagotchi/bundles/system/self_map.md +1 -1
  48. data/lib/samagotchi/cache_confirm.rb +9 -6
  49. data/lib/samagotchi/commands/registry.rb +25 -8
  50. data/lib/samagotchi/config.rb +23 -8
  51. data/lib/samagotchi/context_absorber.rb +66 -13
  52. data/lib/samagotchi/desktop/macos/AgentTerminal.swift +260 -0
  53. data/lib/samagotchi/desktop/macos/App.swift +3 -3
  54. data/lib/samagotchi/desktop/macos/ChiRunner.swift +5 -2
  55. data/lib/samagotchi/desktop/macos/Images.swift +2 -2
  56. data/lib/samagotchi/desktop/macos/Kitty.swift +33 -197
  57. data/lib/samagotchi/desktop/macos/Panel.swift +118 -82
  58. data/lib/samagotchi/desktop/macos/ProcessRunner.swift +1 -1
  59. data/lib/samagotchi/desktop/macos/Tmux.swift +239 -0
  60. data/lib/samagotchi/desktop/macos.rb +84 -13
  61. data/lib/samagotchi/desktop_command.rb +14 -3
  62. data/lib/samagotchi/engine.rb +120 -107
  63. data/lib/samagotchi/engine_peers.rb +3 -0
  64. data/lib/samagotchi/guardrail_wiring.rb +1 -1
  65. data/lib/samagotchi/guardrails/approval.rb +1 -1
  66. data/lib/samagotchi/guardrails/approvals.rb +1 -1
  67. data/lib/samagotchi/guardrails/rules.rb +1 -1
  68. data/lib/samagotchi/guardrails/targets.rb +1 -1
  69. data/lib/samagotchi/hook_routes.rb +151 -0
  70. data/lib/samagotchi/hooks/registry.rb +2 -2
  71. data/lib/samagotchi/host_keys.rb +147 -0
  72. data/lib/samagotchi/kernel_loop.rb +9 -1
  73. data/lib/samagotchi/llm/adapter_common.rb +138 -0
  74. data/lib/samagotchi/llm/anthropic_assembly.rb +4 -2
  75. data/lib/samagotchi/llm/anthropic_messages.rb +28 -105
  76. data/lib/samagotchi/llm/api_key.rb +23 -7
  77. data/lib/samagotchi/llm/chat_loop.rb +10 -6
  78. data/lib/samagotchi/llm/openai_chat.rb +9 -92
  79. data/lib/samagotchi/llm/refusal_memo.rb +29 -0
  80. data/lib/samagotchi/llm_context_override.rb +1 -0
  81. data/lib/samagotchi/peer_sources.rb +58 -0
  82. data/lib/samagotchi/plugin/api.rb +138 -156
  83. data/lib/samagotchi/plugin/context.rb +22 -1
  84. data/lib/samagotchi/plugin/loader.rb +5 -2
  85. data/lib/samagotchi/plugin/peer.rb +394 -0
  86. data/lib/samagotchi/plugin/tool_spec.rb +166 -0
  87. data/lib/samagotchi/plugin_tasks.rb +2 -2
  88. data/lib/samagotchi/row_refs.rb +1 -1
  89. data/lib/samagotchi/self_report.rb +5 -3
  90. data/lib/samagotchi/send_command.rb +41 -15
  91. data/lib/samagotchi/session.rb +18 -1
  92. data/lib/samagotchi/session_commands.rb +16 -1
  93. data/lib/samagotchi/session_continue.rb +58 -15
  94. data/lib/samagotchi/session_inbox.rb +0 -13
  95. data/lib/samagotchi/session_manager.rb +4 -1
  96. data/lib/samagotchi/session_setup.rb +14 -7
  97. data/lib/samagotchi/system_prompt.rb +16 -1
  98. data/lib/samagotchi/terminal_ui/attach_launcher.rb +19 -9
  99. data/lib/samagotchi/terminal_ui.rb +18 -2
  100. data/lib/samagotchi/tool_call_parser.rb +1 -1
  101. data/lib/samagotchi/tool_runner.rb +13 -1
  102. data/lib/samagotchi/toolkit.rb +246 -0
  103. data/lib/samagotchi/toolkit_binding.rb +138 -0
  104. data/lib/samagotchi/toolkit_command.rb +117 -0
  105. data/lib/samagotchi/tools/context_read.rb +14 -0
  106. data/lib/samagotchi/tools/execute.rb +12 -12
  107. data/lib/samagotchi/tools/registry.rb +43 -6
  108. data/lib/samagotchi/tools/task_create.rb +6 -6
  109. data/lib/samagotchi/tools/task_get.rb +13 -13
  110. data/lib/samagotchi/tools/task_list.rb +6 -6
  111. data/lib/samagotchi/tools/task_record.rb +52 -0
  112. data/lib/samagotchi/tools/task_runtime.rb +55 -91
  113. data/lib/samagotchi/tools/task_stop.rb +6 -6
  114. data/lib/samagotchi/tools/task_wait.rb +12 -13
  115. data/lib/samagotchi/tools/tool_index.rb +454 -0
  116. data/lib/samagotchi/update_command.rb +9 -6
  117. data/lib/samagotchi/version.rb +1 -1
  118. data/lib/samagotchi/web/app.rb +28 -9
  119. data/lib/samagotchi/web/capabilities.rb +1 -1
  120. data/lib/samagotchi/web/public/activity.js +2 -2
  121. data/lib/samagotchi/web/public/annotate_actions.js +109 -0
  122. data/lib/samagotchi/web/public/annotate_bar.js +84 -2
  123. data/lib/samagotchi/web/public/app.js +52 -43
  124. data/lib/samagotchi/web/public/chains.js +1 -1
  125. data/lib/samagotchi/web/public/commands.js +6 -4
  126. data/lib/samagotchi/web/public/composer_size.js +2 -2
  127. data/lib/samagotchi/web/public/copy.js +1 -1
  128. data/lib/samagotchi/web/public/ctx.js +1 -1
  129. data/lib/samagotchi/web/public/delivery_line.js +4 -4
  130. data/lib/samagotchi/web/public/families_view.js +1 -1
  131. data/lib/samagotchi/web/public/format.js +11 -7
  132. data/lib/samagotchi/web/public/images.js +25 -2
  133. data/lib/samagotchi/web/public/index.html +9 -0
  134. data/lib/samagotchi/web/public/info_bar.js +5 -3
  135. data/lib/samagotchi/web/public/llm_context_chip.js +2 -2
  136. data/lib/samagotchi/web/public/notify.js +6 -4
  137. data/lib/samagotchi/web/public/question_card.js +1 -1
  138. data/lib/samagotchi/web/public/ref_scan.js +1 -1
  139. data/lib/samagotchi/web/public/session_state.js +17 -0
  140. data/lib/samagotchi/web/public/session_title.js +2 -3
  141. data/lib/samagotchi/web/public/sessions_list.js +0 -8
  142. data/lib/samagotchi/web/public/stage_model.js +2 -2
  143. data/lib/samagotchi/web/public/stream_handlers.js +13 -0
  144. data/lib/samagotchi/web/public/strip.js +3 -3
  145. data/lib/samagotchi/web/public/task_stop.js +3 -2
  146. data/lib/samagotchi/web/public/toolkit_chip.js +29 -0
  147. data/lib/samagotchi/web/public/turn_events.js +1 -1
  148. data/lib/samagotchi/web/public/turn_html.js +2 -2
  149. data/lib/samagotchi/web/public/turn_model.js +2 -2
  150. data/lib/samagotchi/web/public/turn_view.js +1 -1
  151. data/lib/samagotchi/web/server.rb +0 -4
  152. data/lib/samagotchi/worker.rb +13 -8
  153. data/lib/samagotchi/worker_inbound.rb +36 -4
  154. data/lib/samagotchi/worker_wakes.rb +67 -7
  155. metadata +23 -1
data/docs/testing.md CHANGED
@@ -19,7 +19,7 @@ The web specs run in plain node. One that loads lit-html, itself or through a mo
19
19
  `turn_html.js`, `card.js`, `copy.js`, `images.js`, `scope.js`, `command_view.js`, `diff_view.js`, `read_view.js`,
20
20
  `question_cards.js`, `stream_handlers.js`, the chips, `info_bar.js`, `lists_view.js`, `context_chips.js`, `families_view.js`,
21
21
  `chains.js`, `vendor/lit-html.js`) needs a DOM: `import "./dom_shim.js";` as its first import (happy-dom, a devDependency only,
22
- never shipped; about 0.3 s per spec file that loads it; lit reads `document` as it loads). The shim's `markupOf(template)`,
22
+ never shipped; lit reads `document` as it loads). The shim's `markupOf(template)`,
23
23
  `htmlOf(nodes)` and `canon(html)` compare markup without lit's marker comments; the DOM writes text with only `&`, `<`, `>`
24
24
  escaped (a `"` in text stays `"`) and an attribute with `&` and `"` escaped. happy-dom 20 picks the wrong option of a `<select>` whose options lit inserted with
25
25
  `?selected`: read the option marked `selected` (`option[selected]`), not `select.value`; the browser follows the mark.
@@ -27,6 +27,11 @@ A select bound with `.value` instead reads right in both, but lit sets it before
27
27
  `llm_context_chip.js`'s `renderForm` draws twice then. Assert a kept node with `assert.ok(a === b)`: a failing
28
28
  `assert.equal` on two DOM nodes inspects the whole DOM (huge output, can stall the run).
29
29
 
30
+ `npm test` runs every file in one process (`--test-isolation=none`; CI and local runs use Node 24): the files share
31
+ `globalThis`, the shim's one happy-dom window and every module's state: a global a spec sets (stage_view's
32
+ `ResizeObserver` stand-in) is there for the files after it, so set only harmless stand-ins and put anything else
33
+ back (`t.mock` restores itself). One file: `node --test spec/web/public/NAME.test.js`.
34
+
30
35
  Where a composer line goes is decided twice, in Ruby (`ComposerRoute`, both terminals) and in the web
31
36
  (`composer.js#routeComposerLine`). `spec/fixtures/composer_routes.json` holds the cases both answer: rspec
32
37
  (`spec/composer_route_spec.rb`) and `npm test` (`spec/web/public/composer.test.js`) run every row. A change to
@@ -44,8 +49,10 @@ script/affected-specs A..B # another range (A...B: from their merge-b
44
49
 
45
50
  A builder runs these while working, not the full suite: the specs the change touched, the specs named after the
46
51
  files it changed (`lib/samagotchi/foo/bar.rb` → `spec/foo/bar_spec.rb`, `bar_*_spec.rb`…), the specs that read
47
- docs or source as text (its `RULES`: add one when a full run catches what the names missed), then the specs that
48
- name a changed file's class. The default range is the merge-base with main (or origin/main) plus uncommitted and untracked files.
52
+ docs or source as text (its `RULES`: add one when a full run catches what the names missed), then the other name
53
+ matches (a spec named after several changed files first, e.g. `engine_toolkit_spec.rb` for `engine.rb` and
54
+ `toolkit.rb`; then one beside the file, so `plugin/context.rb` picks `spec/samagotchi/plugin/*context*` before
55
+ `spec/guardrails/context_spec.rb`), then the specs that name a changed file's class. The default range is the merge-base with main (or origin/main) plus uncommitted and untracked files.
49
56
  The Ruby specs go to stdout; the node tests to run (`npm test` targets), "web changed: also run npm run e2e" and a
50
57
  hint for a hub class (named in too many specs to list) go to stderr. Nothing printed means nothing matched.
51
58
 
@@ -65,6 +72,13 @@ It runs `git worktree add ../samagotchi-NAME -b BRANCH main`, then `npm ci --pre
65
72
  `npm test` and `npm run e2e` work there right away (Ruby gems and Playwright's browsers are shared: nothing else
66
73
  to install). It refuses when the folder or the branch already exists.
67
74
 
75
+ ## The desktop helper's build specs
76
+
77
+ `SAMAGOTCHI_MACOS_BUILD=1 bundle exec rspec spec/desktop` also compiles the macOS helper (`:macos_build`, needs the
78
+ Command Line Tools) and runs `ChiHelper` against a fake kitty and, when a `tmux` is on PATH, a real one (private
79
+ `-L` servers under a temp `TMUX_TMPDIR`, killed after). CI skips them, and `script/affected-specs` doesn't pick them
80
+ for a Swift change: run them by hand after editing `lib/samagotchi/desktop/macos/`.
81
+
68
82
  ## Integration specs
69
83
 
70
84
  Examples tagged `:integration` (`spec/integration/`, the "with LLM access" part of `spec/hooks/integration_spec.rb`)
@@ -9,6 +9,7 @@ require_relative "../version"
9
9
  require_relative "../llm/openai_chat"
10
10
  require_relative "../llm/http"
11
11
  require_relative "../llm/api_key"
12
+ require_relative "../host_keys"
12
13
  require_relative "../redacted_inspect"
13
14
 
14
15
  module Samagotchi
@@ -229,10 +230,10 @@ module Samagotchi
229
230
  end
230
231
 
231
232
  # One attempt through LLM::HTTP (User-Agent, the key's Bearer header),
232
- # any status returned as is. A key whose variable is not set sends no
233
- # header: the server's 401 then says one is needed.
233
+ # any status returned as is. A key neither its variable nor the
234
+ # Keychain holds sends no header: the server's 401 then says one is needed.
234
235
  def send_request(uri, request, key_env, read_timeout:)
235
- key = key_env && !@env[key_env].to_s.strip.empty? ? LLM::ApiKey.for(key_env, host: uri.host, env: @env) : nil
236
+ key = key_env && HostKeys.fetch(key_env, env: @env) ? LLM::ApiKey.for(key_env, host: uri.host, env: @env) : nil
236
237
  LLM::HTTP.new(label: uri.host, open_timeout: @timeout, read_timeout: read_timeout, api_key: key,
237
238
  retry_policy: LLM::HTTP::RetryPolicy.none)
238
239
  .fetch(uri, request, retries: false, check_status: false, log_fields: { purpose: "probe" })
@@ -4,6 +4,7 @@ require "rbconfig"
4
4
  require_relative "config"
5
5
  require_relative "model_profile"
6
6
  require_relative "host_registry"
7
+ require_relative "host_keys"
7
8
  require_relative "bootstrap/probe"
8
9
  require_relative "bootstrap/config_writer"
9
10
  require_relative "bootstrap/bundles"
@@ -87,7 +88,7 @@ module Samagotchi
87
88
 
88
89
  key_env = options[:key_env]
89
90
  if key_env && unset?(key_env)
90
- fail!("#{key_env} is not set; export it first (the API key; chi only writes its name)")
91
+ fail!("#{key_env} is not set; #{export_hint(key_env, "first")} (the API key; chi only writes its name)")
91
92
  return @exit
92
93
  end
93
94
 
@@ -187,7 +188,7 @@ module Samagotchi
187
188
  key_env = @stdin.gets.to_s.strip
188
189
  return fail!("no variable given") if key_env.empty?
189
190
  return fail!("#{key_env} isn't a variable name") unless key_env.match?(ConfigFile::ENV_NAME_RE)
190
- return fail!("#{key_env} is not set; export it and run chi bootstrap again") if unset?(key_env)
191
+ return fail!("#{key_env} is not set; #{export_hint(key_env, "and run chi bootstrap again")}") if unset?(key_env)
191
192
 
192
193
  result = @probe.classify(result.candidate, key_env: key_env)
193
194
  return fail!("#{host} refused the key in #{key_env} (HTTP #{result.status})") if result.kind == :needs_key
@@ -433,7 +434,16 @@ module Samagotchi
433
434
  @stdin.respond_to?(:tty?) && @stdin.tty?
434
435
  end
435
436
 
436
- def unset?(name) = @env[name].to_s.strip.empty?
437
+ # Neither the variable nor the Keychain (HostKeys) holds the key.
438
+ def unset?(name) = HostKeys.fetch(name, env: @env).nil?
439
+
440
+ # "export it first", or on macOS "export it or store it in the Keychain
441
+ # (security …) first".
442
+ def export_hint(name, tail)
443
+ return "export it #{tail}" unless HostKeys.available?
444
+
445
+ "export it or store it in the Keychain (#{HostKeys.store_command(name)}) #{tail}"
446
+ end
437
447
 
438
448
  def fail!(message)
439
449
  @stderr.puts("chi bootstrap: #{message}")
@@ -40,11 +40,6 @@ module Samagotchi
40
40
  end
41
41
  end
42
42
 
43
- # @return [Integer, nil] the highest seq buffered so far.
44
- def last_seq
45
- @mutex.synchronize { @events.last&.[](:seq) }
46
- end
47
-
48
43
  # @return [Integer, nil] the lowest seq buffered so far.
49
44
  def oldest_seq
50
45
  @mutex.synchronize { @events.first&.[](:seq) }
@@ -374,7 +374,10 @@ module Samagotchi
374
374
 
375
375
  Thread.new { handle_connection(client) }.tap do |t|
376
376
  t.report_on_exception = false
377
- @connection_threads << t
377
+ # The ended connections' threads go as a new one comes, so a
378
+ # long-lived worker's list holds its open connections only. A new
379
+ # Array: #await_answers and #stop may be walking the old one.
380
+ @connection_threads = @connection_threads.select(&:alive?) << t
378
381
  end
379
382
  end
380
383
  rescue StandardError => e
@@ -606,13 +609,13 @@ module Samagotchi
606
609
 
607
610
  record, error = Tools::TaskRuntime.get_record(task_id)
608
611
  return [{}, 404, { error: "task_not_found", task_id: task_id }] if error
609
- return [{}, 409, { error: "not_running", status: record["status"], task_id: task_id }] unless record["status"] == "running"
612
+ return [{}, 409, { error: "not_running", status: record.status, task_id: task_id }] unless record.running?
610
613
 
611
614
  Log.info(:bridge, "task_stop", task_id: task_id)
612
615
  record, error = Tools::TaskRuntime.stop_task(task_id, by: "user")
613
616
  return [{}, 500, { error: "bridge_error", detail: error }] if error
614
617
 
615
- [{}, 200, { status: record["status"], stop_reason: record["stop_reason"], task_id: task_id }]
618
+ [{}, 200, { status: record.status, stop_reason: record.stop_reason, task_id: task_id }]
616
619
  end
617
620
 
618
621
  # The tasks this session started: its history's (created_ids_in) and
@@ -1,22 +1,23 @@
1
1
  ---
2
2
  name: claude-peer
3
- version: 0.1.0
3
+ version: 0.3.0
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-review context; /claude install-skill installs Claude's chi-peer skill, which pairs"
6
6
  trust_level: reviewed
7
7
  files:
8
8
  skill_claude_peer.md:
9
- sha256: sha256:dff0175d0370f43920daf5ecbb81315fdd2c7d8063a33fbefc75c7d0e7312993
10
- description: "Send a part of the work to the paired Claude Code session (claude_send): only when the user asks; validate, context or question; Claude's answer comes as the claude-review context"
9
+ sha256: sha256:9293740f3e1afa0fc2eff381f2c6406087598205df351abd22b4459a3bcb5d0e
10
+ description: "Send a part of the work to the paired Claude Code session (claude_send): only when the user asks, or to reply to its answer in a peer turn; validate, context or question; Claude's answer comes as the claude-review context"
11
11
  plugin:
12
12
  file: plugin.rb
13
- sha256: sha256:bf5a95eb125ba4831466e177295bdaba1f625e255b027cea1e59992310d8300e
13
+ sha256: sha256:74960c46f4b98a453a6c6b16a53a4e9cb76752e6df1deecf55784fc7a0e616ce
14
14
  scripts:
15
15
  pair: sha256:033f1fc6a644950b57e71865253526a0a487bb4c9fd5a61bb89d031b65f40417
16
- chi-peer-SKILL.md: sha256:f35f726b1a86091ff69d289e8dd58794ac6f44879a63630fcc88d69867c56e20
16
+ chi-peer-SKILL.md: sha256:753b609bca01877d46e6b1c3c5b74edba745433cbe33a86702d3ab75bc3f7107
17
17
  # Two things need chi 0.63.0, and the bundle works without them on 0.62:
18
18
  # ctx.context.list's summary: (the /claude card's last answer; without it
19
19
  # the card shows no summary), and a pushed source's first wake: true push
20
20
  # waking the session (on 0.62 Claude's first answer arrives as a note
21
21
  # without waking; later ones wake).
22
- requires_chi: ">= 0.62.0"
22
+ # The plugin needs Samagotchi::Plugin::Peer and chi.peer_source (chi 0.67.0).
23
+ requires_chi: ">= 0.67.0"
@@ -1,12 +1,9 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "fileutils"
4
3
  require "io/wait"
5
4
  require "json"
6
- require "open3"
7
5
  require "socket"
8
- require "tempfile"
9
- require "time"
6
+ require "samagotchi/plugin/peer"
10
7
 
11
8
  # claude-peer (docs/plugins.md, The claude-peer bundle): a live link between
12
9
  # this chi session and a Claude Code session the user works with. Claude
@@ -42,22 +39,28 @@ require "time"
42
39
  # being the user's consent: the copy happens in-process, where no guardrail
43
40
  # sees it. The skill is scripts/chi-peer-SKILL.md beside this file, with the
44
41
  # installed scripts/pair path and the payload folder written in.
45
- class Plugin
42
+ #
43
+ # In the web, selecting text offers "Confirm with Claude" (chi.annotation;
44
+ # skipped on a chi without it, 0.62–0.65): it fills the composer with
45
+ # `/claude validate` and the quote, for the user to add what to confirm.
46
+ # Such a text is titled by its first quoted line, not the quote's
47
+ # `From your thinking:` label.
48
+ #
49
+ # What the peer bundles share (the tool, the command, the cards, payloads)
50
+ # is Samagotchi::Plugin::Peer; this class has Claude's strings and the
51
+ # socket transport.
52
+ class Plugin < Samagotchi::Plugin::Peer
46
53
  INBOX = "claude-inbox"
47
54
  REVIEW = "claude-review"
48
- KINDS = %w[validate context question].freeze
49
- # A part up to this long goes inline: Claude reads it with no file access.
50
- INLINE_CHARS = 4_000
51
- # A part longer than this is refused (Claude takes ~1M; a send is a part,
52
- # not a session).
53
- MAX_CHARS = 200_000
54
- TITLE_CHARS = 80
55
- SLUG_CHARS = 40
55
+ BUNDLE = "claude-peer"
56
+ NAME = "Claude"
57
+ APP = "Claude Code"
58
+ COMMAND = "/claude"
59
+ TOOL = "claude_send"
56
60
  # The socket must take the line within this long.
57
61
  WRITE_SECONDS = 5
58
62
  # The Claude Code version the wire format was checked on.
59
63
  CHECKED_ON = "Claude Code 2.1.296"
60
- LAST_SEND = "last-send.json"
61
64
  STATUS_CARD = "claude"
62
65
  SKILL_CARD = "claude-skill"
63
66
  USAGE = "usage: /claude — whether a Claude Code session is paired; /claude validate <text> | context <text> — send " \
@@ -66,17 +69,15 @@ class Plugin
66
69
  # The shipped bundle keeps scripts/ beside plugin.rb; an installed one
67
70
  # has plugin/plugin.rb and scripts/ beside plugin/.
68
71
  SCRIPTS_DIRS = [File.join(__dir__, "scripts"), File.expand_path("../scripts", __dir__)].freeze
69
- SKILL_FILE = "chi-peer-SKILL.md"
70
- PAIR_PLACEHOLDER = "__CHI_PEER_PAIR__"
71
- PAYLOADS_PLACEHOLDER = "__CHI_PEER_PAYLOADS__"
72
- DIFF_LINES = 20
73
72
 
74
- TOOL_DESCRIPTION = "Send a part of this session's work (a plan step, a diff, a finding, your last answer) to the " \
75
- "Claude Code session paired with it (the claude-inbox context), for Claude to validate, to take " \
76
- "as context, or to answer a question about. Only when the user asks for it. Needs a paired " \
77
- "Claude Code session: without one it fails, and the user pairs from Claude. Claude's answer " \
78
- "comes back later as the claude-review context (a validate's wakes you): don't wait or poll. " \
79
- "Never send secrets or the whole conversation."
73
+ TOOL_DESCRIPTION = "Send a part of this session's work (a plan step, a diff, a finding, your " \
74
+ "last answer) to the Claude Code session paired with it (the claude-inbox " \
75
+ "context), for Claude to validate, to take as context, or to answer a " \
76
+ "question about. Only when the user asks for it, or to reply to its answer " \
77
+ "in a peer turn. Needs a paired Claude Code session: without one it fails, " \
78
+ "and the user pairs from Claude. Claude's answer comes back later as the " \
79
+ "claude-review context (a validate's wakes you): don't wait or poll. Never " \
80
+ "send secrets or the whole conversation."
80
81
  TOOL_PARAMS = {
81
82
  kind: { type: "string", enum: KINDS, required: true,
82
83
  description: "validate: check this part and answer with a verdict; context: for Claude to know, no " \
@@ -88,268 +89,40 @@ class Plugin
88
89
  "functions or diff range to look at. A long text goes to a file Claude reads" }
89
90
  }.freeze
90
91
 
91
- # A send that didn't happen; the message says why, for the model or the user.
92
- class SendError < StandardError; end
93
-
94
- def initialize(_settings = {})
95
- @lock = Mutex.new
96
- # The running turn's history before it (nil between turns), and the
97
- # last completed answer this process saw.
98
- @before_turn = nil
99
- @last_answer = nil
100
- end
101
-
102
- def register(chi)
103
- chi.tool "claude_send", TOOL_DESCRIPTION, params: TOOL_PARAMS, label: "sent to Claude",
104
- preview: ->(args) { "#{args["kind"]}: #{args["title"]}" } do |args, ctx|
105
- sent = deliver(ctx, kind: args["kind"].to_s, title: args["title"].to_s, text: args["text"].to_s)
106
- "#{sent}. #{follow_up(args["kind"].to_s)}"
107
- rescue SendError => e
108
- "Error: #{e.message}"
109
- end
110
- chi.command "/claude", "the paired Claude Code session: status; validate|context <text>, last: send to it; " \
111
- "install-skill: install Claude's chi-peer skill", anytime: true do |args, ctx|
112
- claude(args.to_s.strip, ctx)
113
- end
114
- chi.on(:before_turn) do |event|
115
- @lock.synchronize { @before_turn = Array(event[:messages]) }
116
- end
117
- chi.on(:after_turn) do |event|
118
- answer = completed_answer(event[:messages]) if event[:status].to_s == "completed"
119
- @lock.synchronize do
120
- @before_turn = nil
121
- @last_answer = answer if answer
122
- end
123
- end
124
- end
125
-
126
- # Sends +text+ to the paired Claude: the header, then the text inline or
127
- # a payload file's path. Records it as the last send.
128
- # @return [String] what was sent ("sent to Claude: validate · title, inline")
129
- # @raise [SendError]
130
- def deliver(ctx, kind:, title:, text:)
131
- raise SendError, "kind must be one of #{KINDS.join(", ")}" unless KINDS.include?(kind)
132
- raise SendError, "nothing to send: the text is empty" if text.strip.empty?
133
- if text.length > MAX_CHARS
134
- raise SendError, "the text is #{text.length} chars, over #{MAX_CHARS}: send a part, not a session"
135
- end
136
-
137
- id = ctx.session_id or raise SendError, "this session has no id yet: send after its first turn"
138
- socket = socket_path(ctx)
139
- title = one_line(title.strip.empty? ? text.strip.lines.first : title)
140
- body, where = if text.length <= INLINE_CHARS
141
- [text, "inline"]
142
- else
143
- path = write_payload(ctx, id, title, text)
144
- ["payload: #{path} (#{text.lines.size} lines, #{text.length} chars)", path]
145
- end
146
- post(socket, line("chi #{id[0, 8]} · #{kind} · #{title}\n#{body}"))
147
- record(ctx, id, kind: kind, title: title, chars: text.length, payload: where == "inline" ? nil : where)
148
- "sent to Claude: #{kind} · #{title}#{" (the text in #{where})" unless where == "inline"}"
149
- end
150
-
151
92
  # The one JSON line Claude Code's inbox socket takes.
152
93
  def line(content) = "#{JSON.generate(type: "user", message: { role: "user", content: content })}\n"
153
94
 
154
95
  private
155
96
 
156
- def claude(args, ctx)
157
- verb = args.split(/\s+/, 2).first
158
- # The rest as typed: a pasted selection keeps its lines.
159
- rest = args.sub(/\A\S+\s*/, "")
160
- case verb
161
- when nil then status(ctx)
162
- when "validate", "context" then rest.strip.empty? ? USAGE : send_text(ctx, verb, rest)
163
- when "last" then rest.empty? ? send_last(ctx) : USAGE
164
- when "install-skill" then install_skill(ctx, yes: rest == "--yes")
165
- else USAGE
166
- end
167
- end
168
-
169
- def send_text(ctx, kind, text)
170
- "#{deliver(ctx, kind: kind, title: text.strip.lines.first.to_s, text: text)}; Claude's answer comes as the " \
171
- "claude-review context"
172
- rescue SendError => e
173
- "/claude: #{e.message}"
174
- end
175
-
176
- def send_last(ctx)
177
- answer, before = @lock.synchronize { [@last_answer, @before_turn] }
178
- answer ||= completed_answer(before || ctx.messages)
179
- return "/claude last: no completed answer to send yet (the last turn has none); /claude context <text> sends a text" unless answer
180
-
181
- deliver(ctx, kind: "context", title: "chi's last answer: #{answer.strip.lines.first}", text: answer)
182
- rescue SendError => e
183
- "/claude: #{e.message}"
184
- end
185
-
186
- # The answer a completed turn left: the history's last message when it
187
- # is the model's (a cancelled, failed or empty turn ends in a turn note),
188
- # after any notes that joined between turns; its thinking left out.
189
- def completed_answer(messages)
190
- last = Array(messages).reverse.find { |message| !(role(message) == "system" && kind(message) != "turn_note") }
191
- return nil unless last && role(last) == "model" && !field(last, :interrupted) && !field(last, :tool_calls)
192
-
193
- text = field(last, :content)
194
- text = text.filter_map { |part| field(part, :text) if part.is_a?(Hash) }.join("\n") if text.is_a?(Array)
195
- text = text.to_s.gsub(%r{<think>.*?</think>}m, "").strip
196
- text.empty? ? nil : text
197
- end
198
-
199
- # The session's attached sources; none when they can't be read (chi 0.62
200
- # raises for a new session not saved yet; a corrupt session file).
201
- def sources(ctx)
202
- ctx.context.list
203
- rescue ArgumentError, SystemCallError
204
- []
205
- end
206
-
207
- def role(message) = field(message, :role).to_s
208
- def kind(message) = field(message, :kind).to_s
209
- def field(hash, key) = hash.is_a?(Hash) ? hash[key] || hash[key.to_s] : nil
210
-
211
- # The status card; nil, so the command adds no text.
212
- def status(ctx)
213
- id = ctx.session_id
214
- sources = id ? sources(ctx) : []
215
- inbox, review = [INBOX, REVIEW].map { |name| sources.find { |source| source[:name] == name } }
216
- socket = inbox && inbox[:hint].to_s.strip
217
- lines = if socket.nil? || socket.empty?
218
- ["**not paired.** In Claude Code, install the chi-peer skill (Install skill below), then tell Claude " \
219
- "\"pair with chi #{id.to_s[0, 8]}\"."]
220
- else
221
- there = File.socket?(socket) ? "there" : "**missing**: that Claude session ended or restarted, pair again"
222
- ["**paired** with a Claude Code session", "socket: `#{socket}` (#{there})"]
223
- end
224
- lines << "last send: #{last_send_line(ctx, id)}" if id && last_send(ctx, id)
225
- lines << "last answer (claude-review): #{review_line(review)}" if review
226
- lines << "The inbox's format is undocumented: checked on #{CHECKED_ON}. A Claude that bypasses permissions " \
227
- "holds peer messages for approval."
228
- ctx.card(id: STATUS_CARD, title: "claude-peer: #{lines.first.start_with?("**paired") ? "paired" : "not paired"}",
229
- body: lines.join("\n\n"), actions: [{ label: "Refresh", command: "/claude" },
230
- { label: "Install skill", command: "/claude install-skill" }])
231
- nil
232
- end
233
-
234
- def last_send(ctx, id)
235
- path = File.join(ctx.data_dir, "sessions", id, LAST_SEND)
236
- File.file?(path) ? JSON.parse(File.read(path)) : nil
237
- rescue JSON::ParserError, SystemCallError
238
- nil
239
- end
240
-
241
- def last_send_line(ctx, id)
242
- sent = last_send(ctx, id)
243
- where = sent["payload"] ? "in `#{sent["payload"]}`" : "inline"
244
- "#{ago(sent["at"])} · #{sent["kind"]} · #{sent["title"]} (#{sent["chars"]} chars, #{where})"
97
+ # The paired Claude's inbox socket: the claude-inbox context's hint.
98
+ def check_hint!(hint)
99
+ raise SendError, "claude-inbox's hint isn't a socket path (#{hint}): pair again from Claude" unless hint.start_with?("/")
245
100
  end
246
101
 
247
- # The summary needs chi 0.63.0 (ctx.context.list's summary:).
248
- def review_line(review)
249
- return "none yet" unless review[:fetched_at]
250
-
251
- [review[:summary], ago(review[:fetched_at])].compact.join(" · ")
102
+ def paired_lines(socket)
103
+ there = File.socket?(socket) ? "there" : "**missing**: that Claude session ended or restarted, pair again"
104
+ ["**paired** with a Claude Code session", "socket: `#{socket}` (#{there})"]
252
105
  end
253
106
 
254
- def ago(stamp)
255
- seconds = (Time.now - Time.iso8601(stamp.to_s)).to_i
256
- if seconds < 60 then "just now"
257
- elsif seconds < 3600 then "#{seconds / 60} min ago"
258
- elsif seconds < 86_400 then "#{seconds / 3600} h ago"
259
- else Time.iso8601(stamp).localtime.strftime("%Y-%m-%d %H:%M")
260
- end
261
- rescue ArgumentError
262
- stamp.to_s
107
+ def transport_line
108
+ "The inbox's format is undocumented: checked on #{CHECKED_ON}. A Claude that bypasses permissions holds peer " \
109
+ "messages for approval."
263
110
  end
264
111
 
265
- # /claude install-skill [--yes]
266
- def install_skill(ctx, yes:)
267
- source = SCRIPTS_DIRS.map { |dir| File.join(dir, SKILL_FILE) }.find { |path| File.file?(path) }
268
- return "/claude install-skill: this bundle has no scripts/#{SKILL_FILE}; reinstall it (chi bundle install claude-peer --force)" unless source
269
-
270
- text = File.read(source).gsub(PAIR_PLACEHOLDER, File.join(File.dirname(source), "pair"))
271
- .gsub(PAYLOADS_PLACEHOLDER, tilde(ctx.data_dir))
272
- target = File.join(claude_dir, "skills", "chi-peer", "SKILL.md")
273
- yes ? write_skill(ctx, target, text) : skill_card(ctx, target, text)
274
- end
275
-
276
- def skill_card(ctx, target, text)
277
- current = File.file?(target) ? File.read(target) : nil
278
- state = if current.nil? then "Not installed yet."
279
- elsif current == text then "Installed and up to date."
280
- else "Installed, and different from this bundle's (Replace overwrites it):\n\n```diff\n#{diff(target, text)}\n```"
281
- end
112
+ def setup_paragraph(ctx)
282
113
  rule = "Read(#{tilde(ctx.data_dir)}/**)"
283
- body = ["Claude Code's side: it pairs (`pair with chi <id>`), answers validates and replies into chi.",
284
- "target: `#{target}`", state,
285
- "So Claude isn't asked before reading a long part's file, add `#{rule}` to Claude Code's permissions " \
286
- "(`permissions.allow` in its settings.json); chi doesn't change Claude's settings."]
287
- actions = if current == text
288
- []
289
- else
290
- [{ label: current ? "Replace" : "Install", command: "/claude install-skill --yes" }]
291
- end
292
- ctx.card(id: SKILL_CARD, title: "Claude's chi-peer skill", body: body.join("\n\n"), actions: actions)
293
- nil
294
- end
295
-
296
- def write_skill(ctx, target, text)
297
- FileUtils.mkdir_p(File.dirname(target))
298
- File.write(target, text)
299
- ctx.card(id: SKILL_CARD, title: "Claude's chi-peer skill",
300
- body: "Installed `#{target}`. In Claude Code, say \"pair with chi #{ctx.session_id.to_s[0, 8]}\".")
301
- nil
302
- rescue SystemCallError => e
303
- "/claude install-skill: couldn't write #{target}: #{e.message}"
114
+ "So Claude isn't asked before reading a long part's file, add `#{rule}` to Claude Code's permissions " \
115
+ "(`permissions.allow` in its settings.json); chi doesn't change Claude's settings."
304
116
  end
305
117
 
306
118
  # Claude Code's config folder: CLAUDE_CONFIG_DIR, else ~/.claude.
307
- def claude_dir
119
+ def peer_dir
308
120
  dir = ENV.fetch("CLAUDE_CONFIG_DIR", "").strip
309
121
  dir.empty? ? File.join(Dir.home, ".claude") : File.expand_path(dir)
310
122
  end
311
123
 
312
- # The first lines of `diff -u installed new`.
313
- def diff(target, text)
314
- Tempfile.create(["chi-peer", ".md"]) do |file|
315
- file.write(text)
316
- file.flush
317
- out, = Open3.capture2("diff", "-u", "--label", "installed", "--label", "this bundle's", target, file.path)
318
- lines = out.lines
319
- lines = lines.first(DIFF_LINES) + ["… #{lines.size - DIFF_LINES} more lines\n"] if lines.size > DIFF_LINES
320
- lines.join.chomp
321
- end
322
- rescue SystemCallError
323
- "(diff isn't there to show how)"
324
- end
325
-
326
- def tilde(path)
327
- home = Dir.home
328
- path.start_with?("#{home}/") ? "~#{path.delete_prefix(home)}" : path
329
- end
330
-
331
- def follow_up(kind)
332
- case kind
333
- when "context" then "Claude takes it in; it answers only if something in it is wrong."
334
- else "Claude's answer comes as an update of the claude-review context; don't wait or poll."
335
- end
336
- end
337
-
338
- # The paired Claude's inbox socket: the claude-inbox context's hint.
339
- def socket_path(ctx)
340
- inbox = sources(ctx).find { |source| source[:name] == INBOX }
341
- hint = inbox && inbox[:hint].to_s.strip
342
- if hint.nil? || hint.empty?
343
- raise SendError, "no claude-inbox: pair from Claude (install its skill with /claude install-skill, then " \
344
- "tell Claude \"pair with chi #{ctx.session_id.to_s[0, 8]}\")"
345
- end
346
- raise SendError, "claude-inbox's hint isn't a socket path (#{hint}): pair again from Claude" unless hint.start_with?("/")
347
-
348
- hint
349
- end
350
-
351
- def post(socket, data)
352
- UNIXSocket.open(socket) { |conn| write_all(conn, data) }
124
+ def post(socket, text)
125
+ UNIXSocket.open(socket) { |conn| write_all(conn, line(text)) }
353
126
  rescue Errno::ENOENT, Errno::ECONNREFUSED, Errno::ENOTSOCK
354
127
  raise SendError, "that Claude session is gone (no inbox at #{socket}): pair again from Claude"
355
128
  rescue SystemCallError, IOError => e
@@ -368,39 +141,4 @@ class Plugin
368
141
  data = data.byteslice(written..)
369
142
  end
370
143
  end
371
-
372
- # sessions/<id>/<HHMMSS>-<slug>.md under the bundle's data dir, readable
373
- # by its owner only.
374
- def write_payload(ctx, id, title, text)
375
- dir = session_dir(ctx, id)
376
- base = "#{Time.now.strftime("%H%M%S")}-#{slug(title)}"
377
- path = File.join(dir, "#{base}.md")
378
- n = 1
379
- path = File.join(dir, "#{base}-#{n += 1}.md") while File.exist?(path)
380
- File.open(path, "w", 0o600) { |file| file.write(text) }
381
- path
382
- rescue SystemCallError => e
383
- raise SendError, "couldn't write the payload file: #{e.message}"
384
- end
385
-
386
- def session_dir(ctx, id)
387
- File.join(ctx.data_dir, "sessions", id).tap { |dir| FileUtils.mkdir_p(dir) }
388
- end
389
-
390
- def record(ctx, id, **send)
391
- entry = { at: Time.now.utc.iso8601, **send }
392
- @lock.synchronize { File.write(File.join(session_dir(ctx, id), LAST_SEND), JSON.generate(entry)) }
393
- rescue SystemCallError => e
394
- ctx.log.info(:last_send_not_saved, error: e.message)
395
- end
396
-
397
- def slug(title)
398
- slug = title.downcase.gsub(/[^a-z0-9]+/, "-").gsub(/\A-+|-+\z/, "")[0, SLUG_CHARS].delete_suffix("-")
399
- slug.empty? ? "part" : slug
400
- end
401
-
402
- def one_line(text)
403
- line = text.to_s.gsub(/\s+/, " ").strip
404
- line.length > TITLE_CHARS ? "#{line[0, TITLE_CHARS - 1]}…" : line
405
- end
406
144
  end
@@ -59,6 +59,9 @@ permissions.
59
59
  `file:line` where you can), what is missing, one improvement if it matters,
60
60
  and a last line `VERDICT: OK` / `VERDICT: CHANGES` / `VERDICT: UNSURE (why)`.
61
61
  Verify claims against the code; say what you did not check.
62
+ - A **validate** whose title you answered before is a follow-up round: chi
63
+ fixed what you flagged, or disagrees. Check it again and answer the same
64
+ way; say `VERDICT: OK` once you are satisfied (chi stops the round on it).
62
65
  - **context**: take it in for your own work. No reply, unless something in it
63
66
  contradicts what you know: then reply with that.
64
67
  - **question**: answer it briefly.
@@ -80,9 +83,11 @@ EOF
80
83
  - `wake` for a validate or a question (chi asked and is waiting); no `wake`
81
84
  for a reply to context.
82
85
  - Each answer replaces the last (chi keeps the latest one), so start the
83
- summary with the title you are answering.
84
- - Use `chi send -m "..." <id>` instead only when chi should act on it as a
85
- user-style instruction, or the user asks for it.
86
+ summary with the title you are answering, and end it with your verdict
87
+ (`VERDICT OK`, `VERDICT CHANGES` or `VERDICT UNSURE`): chi doesn't wake for
88
+ an OK.
89
+ - Never answer a chi message with `chi send`: it counts as the user typing,
90
+ outside the round's limit. Use it only when the user asks you to.
86
91
 
87
92
  ## Don't loop
88
93