peerpressure 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 568bc8fcd7edc4a7049a9f6e14c1366a3c4878357f59b04b70049086a87fb8ce
4
+ data.tar.gz: c07c88c93b439d50ca8829e2a38517d157dec3ccd2d5569e6c22833ed75bd242
5
+ SHA512:
6
+ metadata.gz: 8e04c8bcacac90f73ad9ca9970bce97844f2f041ad8be8de69539e9fc7a595fca4a5451fa2f43981f77fed1fd18330497fbcd714c8588f096dd6a6f98c370fdf
7
+ data.tar.gz: ac98e81f2a74782ee0425d32c29c9c0b5ae3ad011f920eb6f6f5fb10ff0fcdfb5cf70590bfc147a2b90484afb9bb8ad9aabdc48af5b9cbbfae49d095bdb99db2
data/CHANGELOG.md ADDED
@@ -0,0 +1,12 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0
4
+
5
+ First release.
6
+
7
+ - `PeerPressure.list` / `find`: every live Claude Code and Codex session, from the session registry.
8
+ - `PeerPressure.send_message`: one-shot send that registers the calling process for the duration, with a `Delivery` status (`:unconfirmed`, `:held`, `:denied`, `:expired`, `:dropped`, `:refused`, `:delivered`).
9
+ - `PeerPressure::Session`: long-lived peer with `on_message`, `inbox`, `wait_for`, `reply`, `notify_when_idle`.
10
+ - `peer list` / `peer send` CLI (exit code 2 when a message is denied, expired, dropped or refused).
11
+ - Codex tasks via the claude-uds-bridge plugin.
12
+ - Verified on macOS with Claude Code 2.1.296. The wire format is unofficial and may change.
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Forrest Chang
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,106 @@
1
+ # peerpressure
2
+
3
+ [![Ruby 3.2+](https://img.shields.io/badge/ruby-3.2%2B-CC342D.svg)](https://www.ruby-lang.org/)
4
+ [![MIT](https://img.shields.io/badge/license-MIT-2d3142.svg)](LICENSE)
5
+ [![Protocol: unofficial](https://img.shields.io/badge/protocol-unofficial-orange.svg)](#caveats-read-these-first)
6
+
7
+ ![Lonely agents in silent terminals, then one offers a rolled-up note: Just one message.](assets/hero.jpg)
8
+
9
+ **Twenty agents. Zero conversations.**
10
+
11
+ Your Claude Code sessions are sitting in separate terminals, ignoring each other, and you are the message bus. peerpressure lets them (and Codex) talk, from Ruby or the shell.
12
+
13
+ > *"Just one message. Nobody will know."*
14
+
15
+ ## 30 seconds
16
+
17
+ ```bash
18
+ gem install peerpressure
19
+
20
+ peer list # every live Claude Code and Codex session on this machine
21
+ peer send "scrum master" "status check, please"
22
+ peer send codex-myproject-3 "can you look at the failing spec?" --wait 30
23
+ ```
24
+
25
+ ```
26
+ codex idle codex-genx-e9 01a11ad5 ~/work/genX
27
+ claude busy oct 2 didx deploy 36216650 ~/rails/thebarpages_rails
28
+ claude idle scrum master 7cb995ae ~/work/cultiv-ai
29
+ ```
30
+
31
+ An **idle** session wakes up, reads the message, and does the thing. Nobody polls, nothing blocks.
32
+
33
+ ## From Ruby
34
+
35
+ ```ruby
36
+ require "peerpressure"
37
+
38
+ PeerPressure.list.each { |p| puts "#{p.kind} #{p.status} #{p.name}" }
39
+
40
+ # One-shot: registers this process for the send, listens a few seconds, cleans up.
41
+ r = PeerPressure.send_message("scrum master", "status check?", from_label: "canvas", wait: 10)
42
+ r[:delivery].status # :unconfirmed, :held, :denied, :expired, :dropped, :refused, :delivered
43
+ r[:messages] # whatever came back within 10 seconds
44
+
45
+ # Long-lived: stays on the line so replies can arrive whenever.
46
+ session = PeerPressure::Session.new(name: "my-dashboard").start
47
+ id = session.send_message("scrum master", "any blockers?")
48
+ session.on_message { |m| puts "#{m.from_name}: #{m.text}" }
49
+ session.reply(msg, "thanks") # answer whoever wrote to you
50
+ session.notify_when_idle("scrum master") # one :idle message when it next finishes
51
+ ```
52
+
53
+ `ref` can be a sessionId, a unique prefix of one (4+ characters), a pid, or an exact name. Names repeat across sessions, so an ambiguous ref raises instead of guessing.
54
+
55
+ ## What is it for
56
+
57
+ - **A form that talks back** (the reason this exists). A dashboard form submit becomes a message to the session that opened it, with no blocked terminal and no wait command that times out. The send and reply half is here; the StreamWeaver canvas relay that wires a form to it is the next piece.
58
+ - **Claude and Codex in one conversation.** Same registry, same call.
59
+ - **A scrum-master session** that other tools can ask for status, and that can ask them back.
60
+ - **Idle notices**: "tell me when that session is done" without polling.
61
+
62
+ ## "Delivered" is not a thing, honestly
63
+
64
+ Ordinary acceptance sends no receipt, so the gem refuses to lie to you.
65
+
66
+ | Status | Meaning |
67
+ |---|---|
68
+ | `:unconfirmed` | Bytes written. Nothing objected. Not proof anyone read it. |
69
+ | `:held` | The receiving session is waiting for its user to approve. |
70
+ | `:delivered` | A held message was released. |
71
+ | `:denied` / `:expired` | The receiver's user said no, or ran out the clock. |
72
+ | `:dropped` / `:refused` | A guard rejected it (rate limit, attachments, loop detection). |
73
+
74
+ `peer send` exits 2 on `denied`, `expired`, `dropped` or `refused`.
75
+
76
+ ## Codex
77
+
78
+ Codex tasks appear in the same list once the [claude-uds-bridge](https://github.com/LeonKohli/claude-uds-bridge) plugin is installed and its hooks are trusted. A task registers when its first turn runs, so an untouched chat is invisible.
79
+
80
+ The plugin compares permission modes. A Codex task running with approvals off and full access (`bypass`) holds messages from senders that declare no mode, and asks its user to approve them. A task that asks before risky actions accepts them directly. peerpressure never declares a mode on your behalf, because faking one defeats the guard.
81
+
82
+ ## How it works
83
+
84
+ Claude Code keeps one JSON record per live session in `~/.claude/sessions/<pid>.json` and an inbox Unix socket per session. Codex tasks join the same registry through the plugin. peerpressure reads the registry, checks the pid is alive and the socket exists, and writes one newline-terminated JSON `user` frame to the socket.
85
+
86
+ Codex drops frames from senders that are not themselves registered peers, so `send_message` registers the calling process (a row plus a private socket) for the duration of the send and removes both afterward. A `Session` stays registered so replies have somewhere to land.
87
+
88
+ ## Caveats (read these first)
89
+
90
+ - **The protocol is unofficial.** The wire format is a Claude Code internal with no compatibility promise. It works on Claude Code 2.1.296. `Sender#build` is the only code that knows the format, and the specs pin it, so an upgrade that breaks it fails loudly in one place.
91
+ - **Messages are prompts.** Any process running as your user can message any session, and the receiving agent treats a peer message as a teammate's request. That is a prompt-injection channel. Never forward untrusted text into a peer message, and never put secrets in one.
92
+ - **Same machine, same user.** Unix sockets, no network, no auth.
93
+ - **While a send or a `Session` is active, your process shows up in every other session's agent list.** It is removed when it stops.
94
+ - **A `/clear` changes a session's id and keeps its pid.** A stale id raises `NotFound` instead of vanishing into the void.
95
+
96
+ ## Tests
97
+
98
+ ```bash
99
+ bundle exec rspec
100
+ ```
101
+
102
+ The specs build a throwaway config directory and real Unix sockets. They never touch your live sessions.
103
+
104
+ ## License
105
+
106
+ MIT
data/docs/for_llms.md ADDED
@@ -0,0 +1,30 @@
1
+ # peerpressure for LLMs
2
+
3
+ Ruby gem + CLI (`exe/peer`) to discover and message live Claude Code and Codex sessions.
4
+
5
+ ## API
6
+
7
+ | Call | Returns |
8
+ |---|---|
9
+ | `PeerPressure.list(config_dir:, exclude_self:)` | `Peer[]`, newest first (`session_id, name, kind(:claude/:codex), pid, cwd, status, started_at, socket_path`) |
10
+ | `PeerPressure.find(ref)` | one `Peer`; raises `NotFound` / `Ambiguous` |
11
+ | `PeerPressure.send_message(ref, text, from_label:, wait: 0)` | `{delivery: Delivery, messages: [Message]}`; `Delivery#status` is `:unconfirmed` (no receipt: NOT proof of delivery), `:held`, `:denied`, `:expired`, `:dropped`, `:refused`, `:delivered`; raises `Undeliverable` |
12
+ | `PeerPressure::Session.new(name:).start` | long-lived peer: `send_message`, `delivery(id)`, `on_message`, `inbox`, `wait_for(from_session:, timeout:)`, `reply(msg, text)`, `notify_when_idle(ref)`, `receipts_for(id)`, `stop` |
13
+
14
+ ## Files
15
+
16
+ - `lib/peerpressure/registry.rb`: reads `<config>/sessions/*.json`, liveness = `kill -0` + socket file; `find` order is sessionId, pid, exact name, sessionId prefix.
17
+ - `lib/peerpressure/sender.rb`: the ONLY file that knows the wire format (`type:"user", msgV:1, msg_id, from, session_id, priority:"next", message:{role,content}`, newline-delimited JSON). Content is prefixed `[from LABEL]` because the socket shows no sender name.
18
+ - `lib/peerpressure/registration.rb`: registers THIS process (row + 0600 socket in a 0700 dir + accept loop into `#frames`). Required because the Codex receiver drops frames whose `from` isn't a live registered peer (`bridge.ts receive()`), silently. Hold one open for long-lived callers (canvas) so replies can arrive.
19
+ - `lib/peerpressure/session.rb`, `message.rb`, `delivery.rb`: long-lived wrapper, parsed inbound frames (message / receipt / idle, envelope unwrapped), and the status fold.
20
+ - `lib/peerpressure/peer.rb`: `kind` is `:codex` iff `entrypoint == "codex-claude-uds-bridge"`.
21
+
22
+ ## Gotchas
23
+
24
+ - Wire format is internal to Claude Code; if sends stop arriving after an upgrade, fix `Sender#build` and the spec together.
25
+ - Replies go to the registered socket, which exists only while a Registration is open; a one-shot CLI call deregisters (after `wait`), so a Codex reply approved later has nowhere to land.
26
+ - Never test against live sessions in specs: use `spec/spec_helper.rb`'s `FakeFleet` (real `UNIXServer` in a tmpdir).
27
+ - A sent message may be held for the user's approval by a stricter-mode receiver; Codex asks before replying.
28
+ - The Codex plugin decides hold vs accept by permission MODE (`app-server.ts permissionMode`): approval `never` + `dangerFullAccess` = bypass; else prompting. A prompting receiver accepts a sender with no declared mode; a bypass receiver holds it. Never fake `from-mode`.
29
+ - `notify_when_idle` is verified live against Claude Code; the idle notice's `detail` is the session's own one-line status.
30
+ - Do not print `CLAUDE_CODE_MESSAGING_TOKEN`; this gem does not need it.
data/exe/peer ADDED
@@ -0,0 +1,49 @@
1
+ #!/usr/bin/env ruby
2
+ $LOAD_PATH.unshift File.expand_path("../lib", __dir__)
3
+ require "peerpressure"
4
+ require "json"
5
+ require "optparse"
6
+
7
+ json = false
8
+ from = nil
9
+ wait = 0
10
+ parser = OptionParser.new do |o|
11
+ o.banner = "Usage: peer list [--json] | send REF [TEXT] [--from LABEL] [--wait SECONDS] (TEXT or stdin)"
12
+ o.on("--json") { json = true }
13
+ o.on("--from LABEL") { |v| from = v }
14
+ o.on("--wait SECONDS", Float, "keep listening this long for replies") { |v| wait = v }
15
+ end
16
+ args = parser.parse(ARGV)
17
+
18
+ begin
19
+ case args.shift
20
+ when "list"
21
+ peers = PeerPressure.list
22
+ if json
23
+ puts JSON.pretty_generate(peers.map(&:to_h_for_json))
24
+ else
25
+ peers.each { |p| puts format("%-6s %-5s %-40s %s %s", p.kind, p.status, p.name, p.session_id[0, 8], p.cwd) }
26
+ end
27
+ when "send"
28
+ ref = args.shift or abort(parser.help)
29
+ text = args.empty? ? $stdin.read : args.join(" ")
30
+ opts = { wait: wait }
31
+ opts[:from_label] = from if from
32
+ result = PeerPressure.send_message(ref, text, **opts)
33
+ d = result[:delivery]
34
+ if json
35
+ puts JSON.generate(result[:delivery].to_h_for_json.merge(replies: result[:messages].map(&:text)))
36
+ else
37
+ puts "sent #{d.msg_id}: #{d.status}#{" (#{d.detail})" if d.detail}"
38
+ puts " (no receipt: bytes written; not proof the session read it)" if d.status == :unconfirmed
39
+ puts " held: the receiving session is waiting for its user to approve" if d.held?
40
+ result[:messages].each { |m| puts "reply from #{m.from_name}: #{m.text}" }
41
+ end
42
+ exit 2 if d.failed?
43
+ else
44
+ abort parser.help
45
+ end
46
+ rescue PeerPressure::Error, ArgumentError => e
47
+ warn "peer: #{e.message}"
48
+ exit 1
49
+ end
@@ -0,0 +1,20 @@
1
+ module PeerPressure
2
+ # What we know about one sent message. Ordinary acceptance sends NO receipt, so :unconfirmed
3
+ # means "bytes written, nothing said against it" and is not proof the session read it. The
4
+ # other statuses come from the receiver: held (waiting on its user), denied, expired, dropped,
5
+ # refused (a guard rejected it), delivered (a held message was released).
6
+ Delivery = Struct.new(:msg_id, :status, :detail, :receipts, keyword_init: true) do
7
+ FAILED = %i[denied expired dropped refused].freeze
8
+
9
+ def self.from(msg_id, messages)
10
+ receipts = messages.select { |m| m.kind == :receipt && m.orig_msg_id == msg_id }
11
+ last = receipts.last
12
+ new(msg_id: msg_id, receipts: receipts, status: last ? last.status.to_sym : :unconfirmed,
13
+ detail: last&.raw&.values_at("status_detail", "reason", "drop_reason")&.compact&.first)
14
+ end
15
+
16
+ def failed? = FAILED.include?(status)
17
+ def held? = status == :held
18
+ def to_h_for_json = { msg_id: msg_id, status: status, detail: detail }
19
+ end
20
+ end
@@ -0,0 +1,31 @@
1
+ module PeerPressure
2
+ # An inbound frame, parsed. kind is :message (a peer wrote to us), :receipt (delivery status
3
+ # for something we sent: held, denied, expired, delivered, dropped, refused) or :idle (a
4
+ # notify_when_idle subscription fired).
5
+ Message = Struct.new(:kind, :text, :from_name, :from_session, :from_address, :msg_id, :orig_msg_id, :status, :detail, :raw, keyword_init: true) do
6
+ ENVELOPE = /\A<cross-session-message\s([^>]*)>\n?(.*?)\n?<\/cross-session-message>\z/m
7
+
8
+ def self.parse(frame)
9
+ return unless frame.is_a?(Hash)
10
+ case [frame["type"], frame["action"]]
11
+ in ["user", _] then parse_user(frame)
12
+ in ["control", "peer_message_status"]
13
+ new(kind: :receipt, from_address: frame["from"], orig_msg_id: frame["orig_msg_id"], status: frame["status"], raw: frame)
14
+ in ["control", "peer_idle_notice"]
15
+ new(kind: :idle, from_address: frame["from"], orig_msg_id: frame["orig_msg_id"], status: frame["state"], detail: frame["detail"], raw: frame)
16
+ else nil
17
+ end
18
+ end
19
+
20
+ def self.parse_user(frame)
21
+ content = frame.dig("message", "content").to_s
22
+ attrs = {}
23
+ if (m = ENVELOPE.match(content))
24
+ m[1].scan(/([\w-]+)="([^"]*)"/) { |k, v| attrs[k] = v }
25
+ content = m[2]
26
+ end
27
+ new(kind: :message, text: content, from_name: attrs["from-name"], from_session: attrs["from-session"] || frame["session_id"],
28
+ from_address: frame["from"], msg_id: frame["msg_id"], raw: frame)
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,22 @@
1
+ module PeerPressure
2
+ # One live session. kind is :claude or :codex (Codex tasks registered by the bridge
3
+ # plugin carry entrypoint "codex-claude-uds-bridge").
4
+ Peer = Struct.new(:session_id, :name, :kind, :pid, :cwd, :status, :started_at, :socket_path, :entrypoint, keyword_init: true) do
5
+ CODEX_ENTRYPOINT = "codex-claude-uds-bridge".freeze
6
+
7
+ def self.from_record(rec)
8
+ new(
9
+ session_id: rec["sessionId"], name: rec["name"], pid: rec["pid"], cwd: rec["cwd"],
10
+ status: rec["status"], entrypoint: rec["entrypoint"], socket_path: rec["messagingSocketPath"],
11
+ kind: rec["entrypoint"] == CODEX_ENTRYPOINT ? :codex : :claude,
12
+ started_at: rec["startedAt"] && Time.at(rec["startedAt"] / 1000.0)
13
+ )
14
+ end
15
+
16
+ def idle? = status == "idle"
17
+
18
+ def to_h_for_json
19
+ to_h.merge(started_at: started_at&.iso8601)
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,94 @@
1
+ require "json"
2
+ require "securerandom"
3
+ require "socket"
4
+ require "open3"
5
+ require "fileutils"
6
+ require "time"
7
+
8
+ module PeerPressure
9
+ # Registers THIS process as a peer: a <config>/sessions/<pid>.json record plus an inbox socket.
10
+ # The Codex bridge silently drops any frame whose `from` is not a registered, live peer's socket
11
+ # address (Claude sessions do not check), so a sender must be registered while it sends. Replies
12
+ # and delivery receipts arrive on this socket and are collected in #frames.
13
+ class Registration
14
+ SOCK_DIR = ENV["PEERPRESSURE_SOCK_DIR"] || "/tmp/cc-socks"
15
+
16
+ attr_reader :session_id, :socket_path, :frames
17
+
18
+ def self.open(**opts)
19
+ reg = new(**opts).start
20
+ begin
21
+ yield reg
22
+ ensure
23
+ reg.stop
24
+ end
25
+ end
26
+
27
+ def initialize(name: "peerpressure", config_dir: ENV["CLAUDE_CONFIG_DIR"] || File.expand_path("~/.claude"), sock_dir: SOCK_DIR)
28
+ @name = name
29
+ @sessions_dir = File.join(config_dir, "sessions")
30
+ @sock_dir = sock_dir
31
+ @session_id = SecureRandom.uuid
32
+ @frames = Queue.new
33
+ end
34
+
35
+ # The address other peers see as `from`, the same encoding the bridge uses.
36
+ def address
37
+ "uds:" + @socket_path.gsub(/[^A-Za-z0-9:_\/.\\-]/) { |c| c.bytes.map { |b| format("%%%02X", b) }.join }
38
+ end
39
+
40
+ def start
41
+ FileUtils.mkdir_p(@sock_dir, mode: 0o700)
42
+ FileUtils.mkdir_p(@sessions_dir)
43
+ @socket_path = File.join(@sock_dir, "#{Process.pid}.sock")
44
+ File.delete(@socket_path) if File.socket?(@socket_path)
45
+ @server = UNIXServer.new(@socket_path)
46
+ File.chmod(0o600, @socket_path)
47
+ @listener = Thread.new { accept_loop }
48
+ write_record
49
+ self
50
+ end
51
+
52
+ def stop
53
+ @listener&.kill
54
+ @server&.close
55
+ File.delete(@socket_path) if @socket_path && File.socket?(@socket_path)
56
+ File.delete(record_path) if File.exist?(record_path)
57
+ end
58
+
59
+ private
60
+
61
+ def record_path = File.join(@sessions_dir, "#{Process.pid}.json")
62
+
63
+ def write_record
64
+ now = (Time.now.to_f * 1000).to_i
65
+ rec = { pid: Process.pid, sessionId: @session_id, cwd: Dir.pwd, startedAt: now, procStart: proc_start,
66
+ peerProtocol: 1, pidDomain: "darwin", kind: "interactive", entrypoint: "peerpressure",
67
+ messagingSocketPath: @socket_path, name: @name, status: "idle", statusUpdatedAt: now, peerFeatures: [] }
68
+ tmp = "#{record_path}.tmp"
69
+ File.write(tmp, JSON.generate(rec), perm: 0o600)
70
+ File.rename(tmp, record_path)
71
+ end
72
+
73
+ # Same value the bridge compares against: `ps -o lstart=` in the C locale and UTC.
74
+ def proc_start
75
+ out, = Open3.capture2({ "LC_ALL" => "C", "TZ" => "UTC" }, "ps", "-o", "lstart=", "-p", Process.pid.to_s)
76
+ out.strip
77
+ end
78
+
79
+ def accept_loop
80
+ loop do
81
+ client = @server.accept
82
+ Thread.new(client) do |c|
83
+ while (line = c.gets)
84
+ @frames << JSON.parse(line) rescue nil
85
+ end
86
+ ensure
87
+ c.close
88
+ end
89
+ end
90
+ rescue IOError, Errno::EBADF
91
+ nil
92
+ end
93
+ end
94
+ end
@@ -0,0 +1,55 @@
1
+ require "json"
2
+ require "time"
3
+
4
+ module PeerPressure
5
+ # Reads the session registry. A record counts as live when its pid still runs and
6
+ # its socket file exists; a stale file can outlive its process.
7
+ class Registry
8
+ attr_reader :dir
9
+
10
+ def initialize(config_dir: ENV["CLAUDE_CONFIG_DIR"] || File.expand_path("~/.claude"), exclude_self: true)
11
+ @dir = File.join(config_dir, "sessions")
12
+ @self_id = exclude_self ? ENV["CLAUDE_CODE_SESSION_ID"] : nil
13
+ end
14
+
15
+ def peers
16
+ Dir.glob(File.join(dir, "*.json")).filter_map { |f| load_peer(f) }
17
+ .reject { |p| p.session_id == @self_id }
18
+ .sort_by { |p| p.started_at || Time.at(0) }.reverse
19
+ end
20
+
21
+ # ref: full sessionId, unique sessionId prefix (4+ chars), pid, or exact name.
22
+ def find(ref)
23
+ ref = ref.to_s
24
+ all = peers
25
+ hits = all.select { |p| p.session_id == ref }
26
+ hits = all.select { |p| p.pid.to_s == ref } if hits.empty? && ref.match?(/\A\d+\z/)
27
+ hits = all.select { |p| p.name == ref } if hits.empty?
28
+ hits = all.select { |p| p.session_id.to_s.start_with?(ref) } if hits.empty? && ref.length >= 4
29
+ raise NotFound, "no live session matches #{ref.inspect}" if hits.empty?
30
+ raise Ambiguous, "#{ref.inspect} matches #{hits.size} sessions; use a sessionId (#{hits.map { |p| p.session_id[0, 8] }.join(', ')})" if hits.size > 1
31
+ hits.first
32
+ end
33
+
34
+ private
35
+
36
+ def load_peer(file)
37
+ rec = JSON.parse(File.read(file))
38
+ return unless rec.is_a?(Hash) && rec["sessionId"] && rec["messagingSocketPath"]
39
+ return unless File.basename(file) == "#{rec['pid']}.json"
40
+ return unless alive?(rec["pid"]) && File.socket?(rec["messagingSocketPath"])
41
+ Peer.from_record(rec)
42
+ rescue JSON::ParserError, SystemCallError
43
+ nil
44
+ end
45
+
46
+ def alive?(pid)
47
+ Process.kill(0, pid)
48
+ true
49
+ rescue Errno::EPERM
50
+ true
51
+ rescue Errno::ESRCH, TypeError
52
+ false
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,56 @@
1
+ require "json"
2
+ require "securerandom"
3
+ require "socket"
4
+
5
+ module PeerPressure
6
+ # Writes one "user" frame to a session's inbox socket. Ordinary acceptance sends no
7
+ # receipt, so a successful return means the bytes reached the socket, not that the
8
+ # session read them: a session in a stricter permission mode may hold the message
9
+ # for its user.
10
+ class Sender
11
+ MAX_LINE = 1_048_576 # characters per frame, per the bridge's protocol notes
12
+ SETTLE = 0.3 # the macOS receiver can lose the last frame if the sender closes or deregisters at once
13
+
14
+ def initialize(from_label: "peerpressure", priority: "next", from: nil)
15
+ @from_label = from_label
16
+ @priority = priority
17
+ @from = from
18
+ end
19
+
20
+ # Returns the msg_id.
21
+ def deliver(peer, text)
22
+ frame = build(peer, text)
23
+ line = JSON.generate(frame)
24
+ raise Undeliverable, "message too long (#{line.length} > #{MAX_LINE})" if line.length > MAX_LINE
25
+ write(peer.socket_path, line + "\n")
26
+ frame[:msg_id]
27
+ end
28
+
29
+ # Asks the peer for ONE peer_idle_notice when it next goes idle (or exits). Costs it nothing.
30
+ # The notice arrives as a Message(kind: :idle).
31
+ def subscribe_idle(peer)
32
+ frame = { type: "control", msgV: 1, action: "notify_when_idle", msg_id: SecureRandom.uuid,
33
+ from: @from || "uds:/tmp/cc-socks/#{@from_label}-#{Process.pid}.sock", session_id: peer.session_id }
34
+ write(peer.socket_path, JSON.generate(frame) + "\n")
35
+ frame[:msg_id]
36
+ end
37
+
38
+ def build(peer, text)
39
+ raise ArgumentError, "empty message" if text.to_s.strip.empty?
40
+ {
41
+ type: "user", msgV: 1, msg_id: SecureRandom.uuid,
42
+ from: @from || "uds:/tmp/cc-socks/#{@from_label}-#{Process.pid}.sock",
43
+ session_id: peer.session_id, priority: @priority,
44
+ message: { role: "user", content: "[from #{@from_label}] #{text}" }
45
+ }
46
+ end
47
+
48
+ private
49
+
50
+ def write(path, data)
51
+ UNIXSocket.open(path) { |sock| sock.write(data); sleep 0.15 }
52
+ rescue Errno::ENOENT, Errno::ECONNREFUSED, Errno::EACCES => e
53
+ raise Undeliverable, "#{e.class.name.split('::').last} on #{path}"
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,121 @@
1
+ module PeerPressure
2
+ # A long-lived peer: stays registered so other agents can reply to it, and keeps what arrives.
3
+ # Use one per process (a canvas server, a worker). Replies show up through #on_message, #inbox
4
+ # or #wait_for; delivery receipts through #receipts_for.
5
+ #
6
+ # session = PeerPressure::Session.new(name: "streamweaver-canvas").start
7
+ # id = session.send_message("scrum master oct 8 2026", "status check?")
8
+ # reply = session.wait_for(from_session: session.find("scrum master oct 8 2026").session_id, timeout: 60)
9
+ class Session
10
+ attr_reader :name
11
+
12
+ def initialize(name:, config_dir: ENV["CLAUDE_CONFIG_DIR"] || File.expand_path("~/.claude"), sock_dir: Registration::SOCK_DIR)
13
+ @name = name
14
+ @registry = Registry.new(config_dir: config_dir)
15
+ @registration = Registration.new(name: name, config_dir: config_dir, sock_dir: sock_dir)
16
+ @sender = nil
17
+ @handlers = []
18
+ @inbox = []
19
+ @receipts = Hash.new { |h, k| h[k] = [] }
20
+ @lock = Mutex.new
21
+ @cond = ConditionVariable.new
22
+ end
23
+
24
+ def self.open(**opts)
25
+ session = new(**opts).start
26
+ begin
27
+ yield session
28
+ ensure
29
+ session.stop
30
+ end
31
+ end
32
+
33
+ def start
34
+ @registration.start
35
+ @sender = Sender.new(from_label: @name, from: @registration.address)
36
+ @pump = Thread.new { pump }
37
+ at_exit { stop }
38
+ self
39
+ end
40
+
41
+ def stop
42
+ @pump&.kill
43
+ @pump = nil
44
+ @registration.stop
45
+ end
46
+
47
+ def session_id = @registration.session_id
48
+ def peers = @registry.peers
49
+ def find(ref) = @registry.find(ref)
50
+
51
+ # ref: anything Registry#find accepts, or a Peer. Returns the msg_id.
52
+ def send_message(ref, text)
53
+ peer = ref.is_a?(Peer) ? ref : find(ref)
54
+ @sender.deliver(peer, text)
55
+ end
56
+
57
+ # Delivery for a sent msg_id, waiting up to `timeout` for a receipt (held/denied/...). With
58
+ # no receipt the status is :unconfirmed, never "delivered".
59
+ def delivery(msg_id, timeout: 2)
60
+ deadline = Time.now + timeout
61
+ sleep 0.05 while receipts_for(msg_id).empty? && Time.now < deadline
62
+ Delivery.from(msg_id, receipts_for(msg_id))
63
+ end
64
+
65
+ # One :idle Message arrives when the peer next goes idle.
66
+ def notify_when_idle(ref)
67
+ peer = ref.is_a?(Peer) ? ref : find(ref)
68
+ @sender.subscribe_idle(peer)
69
+ end
70
+
71
+ # Answers a received Message, addressing its sender by session id.
72
+ def reply(message, text)
73
+ raise Error, "message has no sender session" unless message.from_session
74
+ send_message(find(message.from_session), text)
75
+ end
76
+
77
+ def on_message(&block)
78
+ @lock.synchronize { @handlers << block }
79
+ self
80
+ end
81
+
82
+ # Messages received so far, oldest first; drains by default.
83
+ def inbox(drain: true)
84
+ @lock.synchronize { drain ? @inbox.shift(@inbox.size) : @inbox.dup }
85
+ end
86
+
87
+ def receipts_for(msg_id) = @lock.synchronize { @receipts[msg_id].dup }
88
+
89
+ # Blocks for the next unread message (optionally only from one session); nil on timeout.
90
+ def wait_for(from_session: nil, timeout: 30)
91
+ deadline = Time.now + timeout
92
+ @lock.synchronize do
93
+ loop do
94
+ if (i = @inbox.index { |m| from_session.nil? || m.from_session == from_session })
95
+ return @inbox.delete_at(i)
96
+ end
97
+ left = deadline - Time.now
98
+ return nil if left <= 0
99
+ @cond.wait(@lock, left)
100
+ end
101
+ end
102
+ end
103
+
104
+ private
105
+
106
+ def pump
107
+ loop do
108
+ msg = Message.parse(@registration.frames.pop) or next
109
+ handlers = @lock.synchronize do
110
+ case msg.kind
111
+ when :receipt then @receipts[msg.orig_msg_id] << msg
112
+ else @inbox << msg
113
+ end
114
+ @cond.broadcast
115
+ @handlers.dup
116
+ end
117
+ handlers.each { |h| h.call(msg) } if msg.kind == :message
118
+ end
119
+ end
120
+ end
121
+ end
@@ -0,0 +1,3 @@
1
+ module PeerPressure
2
+ VERSION = "0.1.0"
3
+ end
@@ -0,0 +1,39 @@
1
+ require_relative "peerpressure/version"
2
+ require_relative "peerpressure/peer"
3
+ require_relative "peerpressure/registry"
4
+ require_relative "peerpressure/registration"
5
+ require_relative "peerpressure/message"
6
+ require_relative "peerpressure/delivery"
7
+ require_relative "peerpressure/sender"
8
+ require_relative "peerpressure/session"
9
+
10
+ # List and message live Claude Code and Codex sessions on this machine.
11
+ #
12
+ # Claude Code keeps one JSON record per live session in <config>/sessions/<pid>.json
13
+ # and an inbox Unix socket per session. Codex tasks join the same registry through
14
+ # the claude-uds-bridge plugin. The wire format is a Claude Code internal with no
15
+ # compatibility promise, so Sender is the only place that knows it.
16
+ module PeerPressure
17
+ class Error < StandardError; end
18
+ class NotFound < Error; end
19
+ class Ambiguous < Error; end
20
+ class Undeliverable < Error; end
21
+
22
+ def self.list(**opts) = Registry.new(**opts).peers
23
+
24
+ def self.find(ref, **opts) = Registry.new(**opts).find(ref)
25
+
26
+ # Registers this process as a peer for the duration of the send (the Codex bridge drops frames
27
+ # from unregistered senders) and listens `wait` seconds (at least Sender::SETTLE). Returns a
28
+ # Delivery (status :unconfirmed / :held / :denied / ...) plus any replies as `messages`.
29
+ def self.send_message(ref, text, registry: Registry.new, from_label: "peerpressure", wait: 0, **opts)
30
+ peer = registry.find(ref)
31
+ Registration.open(name: from_label, config_dir: File.dirname(registry.dir)) do |reg|
32
+ id = Sender.new(from_label: from_label, from: reg.address, **opts).deliver(peer, text)
33
+ deadline = Time.now + [wait, Sender::SETTLE].max
34
+ sleep 0.05 while Time.now < deadline
35
+ messages = Array.new(reg.frames.size) { Message.parse(reg.frames.pop) }.compact
36
+ { delivery: Delivery.from(id, messages), messages: messages.select { |m| m.kind == :message } }
37
+ end
38
+ end
39
+ end
metadata ADDED
@@ -0,0 +1,107 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: peerpressure
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Forrest Chang
8
+ autorequire:
9
+ bindir: exe
10
+ cert_chain: []
11
+ date: 2026-10-10 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: ostruct
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '0.6'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '0.6'
27
+ - !ruby/object:Gem::Dependency
28
+ name: rake
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '13.0'
34
+ type: :development
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '13.0'
41
+ - !ruby/object:Gem::Dependency
42
+ name: rspec
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: '3.13'
48
+ type: :development
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: '3.13'
55
+ description: List live Claude Code and Codex sessions, send them messages, and hear
56
+ back, from Ruby or the peer CLI. Idle sessions wake up and act. Honest delivery
57
+ status (held, denied, expired) instead of a fake 'sent'. Local Unix sockets, same
58
+ user, no network.
59
+ email:
60
+ executables:
61
+ - peer
62
+ extensions: []
63
+ extra_rdoc_files: []
64
+ files:
65
+ - CHANGELOG.md
66
+ - LICENSE
67
+ - README.md
68
+ - docs/for_llms.md
69
+ - exe/peer
70
+ - lib/peerpressure.rb
71
+ - lib/peerpressure/delivery.rb
72
+ - lib/peerpressure/message.rb
73
+ - lib/peerpressure/peer.rb
74
+ - lib/peerpressure/registration.rb
75
+ - lib/peerpressure/registry.rb
76
+ - lib/peerpressure/sender.rb
77
+ - lib/peerpressure/session.rb
78
+ - lib/peerpressure/version.rb
79
+ homepage: https://github.com/fkchang/peerpressure
80
+ licenses:
81
+ - MIT
82
+ metadata:
83
+ source_code_uri: https://github.com/fkchang/peerpressure
84
+ changelog_uri: https://github.com/fkchang/peerpressure/blob/main/CHANGELOG.md
85
+ bug_tracker_uri: https://github.com/fkchang/peerpressure/issues
86
+ rubygems_mfa_required: 'true'
87
+ post_install_message:
88
+ rdoc_options: []
89
+ require_paths:
90
+ - lib
91
+ required_ruby_version: !ruby/object:Gem::Requirement
92
+ requirements:
93
+ - - ">="
94
+ - !ruby/object:Gem::Version
95
+ version: '3.2'
96
+ required_rubygems_version: !ruby/object:Gem::Requirement
97
+ requirements:
98
+ - - ">="
99
+ - !ruby/object:Gem::Version
100
+ version: '0'
101
+ requirements: []
102
+ rubygems_version: 3.5.16
103
+ signing_key:
104
+ specification_version: 4
105
+ summary: 'Message other AI agents on your machine: list and message live Claude Code
106
+ and Codex sessions'
107
+ test_files: []