pi-agent-rb 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: 9ea32a398a43c159c3e7afaad55542aaf726215675960c52da2dea2fe8e29675
4
+ data.tar.gz: d8fe5e18907cea1da1ee0f8a0698be286ecd02792232b69bc419dd79e2c6de52
5
+ SHA512:
6
+ metadata.gz: 8cad377c04b0c45fd6eaa2dd29cdfff690e250431caaf479ca0819d8ab0682c689be9bdabc63e7a57a6bc395d0f9e1bc24b2a2847b946151af33727f7a539f26
7
+ data.tar.gz: 1d6fa73ab6578a09cafea186979879ab501480b951a4ee3f5fe25113282068ce56ea5774911b66463ef9201d75c1a9cab107ffe95b0ec6af9de60e481fb82385
data/CHANGELOG.md ADDED
@@ -0,0 +1,22 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [Unreleased]
9
+
10
+ ## [0.1.0] - 2026-05-27
11
+
12
+ ### Added
13
+ - Initial project scaffold.
14
+ - `Session#run` single-shot helper (pi print-mode equivalent).
15
+ - `Session` commands: `cycle_model`, `available_models`, `messages`,
16
+ `last_assistant_text`, `compact`, `new_session`, `switch_session`.
17
+
18
+ ### Fixed
19
+ - `Session#set_model` now sends `provider`/`modelId` (pi rejected the
20
+ previous single `model` field).
21
+ - `Session#set_thinking` now sends the `set_thinking_level` command (pi
22
+ has no `set_thinking` command).
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 MGC
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,151 @@
1
+ # pi-agent-rb
2
+
3
+ Ruby client for the [pi coding agent](https://github.com/earendil-works/pi).
4
+ Spawns `pi --mode rpc` and speaks its JSONL protocol from Ruby. Designed for
5
+ building interactive agent UIs (web, TUI) on top of pi.
6
+
7
+ > Not officially maintained by the pi project.
8
+
9
+ ## Requirements
10
+
11
+ - Ruby 3.2+
12
+ - `pi` on `PATH` (install via `npm i -g @earendil-works/pi-coding-agent`)
13
+ - This gem is pinned against pi `0.75.3`; other versions may work but are not verified.
14
+
15
+ ## Quick start
16
+
17
+ ```ruby
18
+ require "pi_agent"
19
+
20
+ PiAgent.session do |session|
21
+ session.prompt("Write a haiku about Ruby") do |event|
22
+ print event.delta if event.type == :message_update
23
+ end
24
+ end
25
+ ```
26
+
27
+ A pi RPC process hosts one session, so there is no create/select step —
28
+ `PiAgent.session` spawns `pi --mode rpc` and the session *is* that process.
29
+
30
+ `prompt` yields each [`Event`](lib/pi_agent/event.rb) until the agent
31
+ finishes (`agent_end`). Without a block it returns an `Enumerator`:
32
+
33
+ ```ruby
34
+ PiAgent.session do |session|
35
+ events = session.prompt("List three primes")
36
+ text = events.filter_map(&:delta).join
37
+ puts text
38
+ end
39
+ ```
40
+
41
+ For a single-shot call, `run` submits a prompt, drains the stream, and
42
+ returns the final assistant text — pi's print mode:
43
+
44
+ ```ruby
45
+ PiAgent.session do |session|
46
+ puts session.run("What's 2 + 2?") # => "4"
47
+ end
48
+ ```
49
+
50
+ Other session methods:
51
+
52
+ - Prompting: `steer`, `follow_up`, `abort`
53
+ - Model: `set_model`, `cycle_model`, `available_models`, `set_thinking`
54
+ - State: `get_state`, `messages`, `last_assistant_text`, `session_stats`
55
+ - Context: `compact`
56
+ - Sessions: `new_session`, `switch_session`, `fork`, `clone_session`,
57
+ `set_session_name`
58
+
59
+ `set_model` accepts either `set_model("anthropic/claude-sonnet-4-5")` or
60
+ `set_model("anthropic", "claude-sonnet-4-5")`.
61
+
62
+ ### Images
63
+
64
+ `prompt`, `steer`, and `follow_up` accept an `images:` array. Entries
65
+ may be `PiAgent::Image` objects, file path strings, or raw
66
+ `ImageContent` hashes, mixed freely:
67
+
68
+ ```ruby
69
+ PiAgent.session do |session|
70
+ session.prompt("What's in these?", images: [
71
+ "screenshot.png", # path
72
+ PiAgent::Image.from_file("diagram.jpg"), # Image object
73
+ PiAgent::Image.from_bytes(blob, mime_type: "image/webp")
74
+ ]) { |e| ... }
75
+ end
76
+ ```
77
+
78
+ Supported formats: png, jpeg, gif, webp.
79
+
80
+ For low-level RPC access (raw `request`/`notify`/`subscribe`), use
81
+ `PiAgent.open`, which yields a `PiAgent::Client`.
82
+
83
+ ## Extension UI
84
+
85
+ pi extensions can request user interaction (confirm, select, input,
86
+ editor) mid-run. Pass an `extension_ui` handler to answer them:
87
+
88
+ ```ruby
89
+ handler = lambda do |req|
90
+ case req.method
91
+ when :confirm then true # confirmed
92
+ when :select then req.options.first # pick an option
93
+ when :input then "default" # entered text
94
+ when :editor then req.prefill # edited text
95
+ # fire-and-forget (:notify, :set_status, ...) — return value ignored
96
+ end
97
+ end
98
+
99
+ PiAgent.session(extension_ui: handler) do |session|
100
+ session.prompt("Refactor the parser") { |e| ... }
101
+ end
102
+ ```
103
+
104
+ Returning `nil` from a dialog handler cancels it. With no handler,
105
+ dialogs are auto-cancelled so the agent never hangs. Handlers run on
106
+ their own thread and never block the event stream.
107
+
108
+ ## Forking
109
+
110
+ ```ruby
111
+ PiAgent.session do |session|
112
+ session.prompt("Add a feature") { |e| ... }
113
+
114
+ # Branch from an earlier message
115
+ forkable = session.fork_messages # [{ "entryId" =>, "text" => }]
116
+ session.fork(forkable.first["entryId"]) # => { "text" =>, "cancelled" => }
117
+
118
+ session.clone_session # duplicate the active branch
119
+ session.set_session_name("feature-work")
120
+ end
121
+ ```
122
+
123
+ `fork`/`clone_session` return `cancelled: true` (rather than raising) if
124
+ a pi extension vetoes the operation — that is an expected outcome, not
125
+ an error.
126
+
127
+ ## Errors
128
+
129
+ - A failed RPC command (`success: false`) raises `PiAgent::CommandError`,
130
+ which carries the failing `#command` name.
131
+ - Agent-side errors arrive *in* the event stream, not as exceptions —
132
+ inspect them with `Event#error?`, `#error_message`, and `#error_reason`
133
+ (`"aborted"` vs `"error"`). This covers `extension_error` events and
134
+ errored assistant turns. The gem does not abort your iteration on
135
+ agent errors; you decide how to react.
136
+
137
+ ## Protocol reference
138
+
139
+ The wire protocol is documented upstream in
140
+ [`packages/coding-agent/docs/rpc.md`](https://github.com/earendil-works/pi/blob/main/packages/coding-agent/docs/rpc.md).
141
+
142
+ ## Development
143
+
144
+ ```bash
145
+ bin/setup # bundle install
146
+ bundle exec rspec
147
+ ```
148
+
149
+ ## License
150
+
151
+ MIT
@@ -0,0 +1,170 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PiAgent
4
+ # High-level client. Owns a Transport, correlates request/response by id,
5
+ # and fans notifications out to subscribers.
6
+ #
7
+ # client = PiAgent::Client.new.start
8
+ # client.subscribe { |msg| ... } # all server-pushed messages
9
+ # future = client.request("get_commands") # request/response
10
+ # future.value!(timeout: 5) # blocks for response
11
+ # client.notify("set_thinking", level: "off") # fire-and-forget (no id)
12
+ # client.close
13
+ #
14
+ # By default the client spawns `pi --mode rpc` as a local subprocess.
15
+ # Pass `transport_factory:` — a callable `(on_message:, on_stderr:) ->
16
+ # transport` — to run pi somewhere else (e.g. inside a remote sandbox).
17
+ # See Transport for the transport contract.
18
+ class Client
19
+ DEFAULT_BIN = "pi"
20
+ DEFAULT_ARGS = ["--mode", "rpc"].freeze
21
+
22
+ attr_reader :bin
23
+
24
+ def self.resolve_bin(override = nil)
25
+ candidate = override || ENV["PI_BIN"] || DEFAULT_BIN
26
+ path = which(candidate)
27
+ return path if path
28
+
29
+ raise BinaryNotFoundError, <<~MSG
30
+ Could not find the `pi` binary on PATH (looked for #{candidate.inspect}).
31
+
32
+ Install with: npm install -g @earendil-works/pi-coding-agent@#{PiAgent::SUPPORTED_PI_VERSION}
33
+ Or set PI_BIN to an explicit path.
34
+ MSG
35
+ end
36
+
37
+ def self.which(cmd)
38
+ exts = ENV["PATHEXT"] ? ENV["PATHEXT"].split(";") : [""]
39
+ ENV["PATH"].to_s.split(File::PATH_SEPARATOR).each do |dir|
40
+ exts.each do |ext|
41
+ candidate = File.join(dir, "#{cmd}#{ext}")
42
+ return candidate if File.executable?(candidate) && !File.directory?(candidate)
43
+ end
44
+ end
45
+ nil
46
+ end
47
+
48
+ def initialize(bin: nil, args: DEFAULT_ARGS, env: {}, cwd: nil, extension_ui: nil, transport_factory: nil)
49
+ @extension_ui_handler = extension_ui
50
+ @transport_factory = transport_factory || build_subprocess_factory(bin, args, env, cwd)
51
+ @pending = {}
52
+ @pending_mutex = Mutex.new
53
+ @next_id = 0
54
+ @subscribers = []
55
+ @subscribers_mutex = Mutex.new
56
+ @transport = nil
57
+ @extension_ui = nil
58
+ end
59
+
60
+ def start
61
+ @transport = @transport_factory.call(
62
+ on_message: method(:handle_message),
63
+ on_stderr: method(:handle_stderr)
64
+ )
65
+ @extension_ui = ExtensionUI.new(writer: @transport, handler: @extension_ui_handler)
66
+ @transport.start
67
+ self
68
+ end
69
+
70
+ def request(type, params = {})
71
+ id = next_id
72
+ future = Future.new
73
+ @pending_mutex.synchronize { @pending[id] = future }
74
+ payload = { id: id, type: type }.merge(params)
75
+ @transport.write(payload)
76
+ future
77
+ end
78
+
79
+ def notify(type, params = {})
80
+ payload = { type: type }.merge(params)
81
+ @transport.write(payload)
82
+ end
83
+
84
+ def subscribe(&block)
85
+ raise ArgumentError, "subscribe requires a block" unless block
86
+
87
+ @subscribers_mutex.synchronize { @subscribers << block }
88
+ block
89
+ end
90
+
91
+ def unsubscribe(handle)
92
+ @subscribers_mutex.synchronize { @subscribers.delete(handle) }
93
+ end
94
+
95
+ def close
96
+ # Drain extension UI handler threads while the transport is still
97
+ # open so their responses can still be written.
98
+ @extension_ui&.shutdown
99
+ @transport&.close
100
+ reject_pending(ProtocolError.new("Transport closed before response"))
101
+ end
102
+
103
+ def alive?
104
+ @transport&.alive? || false
105
+ end
106
+
107
+ private
108
+
109
+ # Default factory: resolve the pi binary now (so a missing binary
110
+ # fails fast at construction) and build a subprocess transport on
111
+ # start, once Client's message handlers are known.
112
+ def build_subprocess_factory(bin, args, env, cwd)
113
+ @bin = self.class.resolve_bin(bin)
114
+ command = [@bin, *Array(args)]
115
+ lambda do |on_message:, on_stderr:|
116
+ Transport::Subprocess.new(
117
+ command: command, env: env, cwd: cwd,
118
+ on_message: on_message, on_stderr: on_stderr
119
+ )
120
+ end
121
+ end
122
+
123
+ def next_id
124
+ @pending_mutex.synchronize do
125
+ @next_id += 1
126
+ "req-#{@next_id}"
127
+ end
128
+ end
129
+
130
+ def handle_message(msg)
131
+ type = msg["type"]
132
+ if type == "response" && msg["id"]
133
+ deliver_response(msg)
134
+ elsif type == "extension_ui_request"
135
+ @extension_ui&.dispatch(msg)
136
+ else
137
+ notify_subscribers(msg)
138
+ end
139
+ end
140
+
141
+ def deliver_response(msg)
142
+ future = @pending_mutex.synchronize { @pending.delete(msg["id"]) }
143
+ return unless future
144
+
145
+ if msg["success"] == false
146
+ future.reject(CommandError.new(msg["error"] || "command failed: #{msg.inspect}", command: msg["command"]))
147
+ else
148
+ future.resolve(msg)
149
+ end
150
+ end
151
+
152
+ def notify_subscribers(msg)
153
+ callbacks = @subscribers_mutex.synchronize { @subscribers.dup }
154
+ callbacks.each { |cb| cb.call(msg) }
155
+ end
156
+
157
+ def handle_stderr(line)
158
+ # no-op by default; future versions may wire a logger
159
+ end
160
+
161
+ def reject_pending(error)
162
+ pending = @pending_mutex.synchronize do
163
+ snapshot = @pending.values
164
+ @pending.clear
165
+ snapshot
166
+ end
167
+ pending.each { |f| f.reject(error) }
168
+ end
169
+ end
170
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PiAgent
4
+ class Error < StandardError; end
5
+
6
+ class BinaryNotFoundError < Error; end
7
+ class VersionMismatchError < Error; end
8
+ class ProtocolError < Error; end
9
+ class SessionError < Error; end
10
+ class TimeoutError < Error; end
11
+
12
+ # Raised when an RPC command returns `success: false`. Carries the
13
+ # failing command name so callers can branch on it.
14
+ class CommandError < Error
15
+ attr_reader :command
16
+
17
+ def initialize(message, command: nil)
18
+ @command = command
19
+ super(message)
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PiAgent
4
+ # Thin typed wrapper over a pi RPC event message. The native JSON payload
5
+ # is preserved on `#raw` so callers can reach fields we haven't given a
6
+ # dedicated accessor yet.
7
+ #
8
+ # Event types are exposed as Ruby symbols (e.g. `:text_delta`,
9
+ # `:agent_end`) matching the upstream protocol's `type` field.
10
+ class Event
11
+ # Event types that terminate a single prompt's event stream.
12
+ # `agent_end` fires when the agent finishes processing the current
13
+ # prompt cycle; we stop iterating then.
14
+ TERMINAL_TYPES = %i[agent_end].freeze
15
+
16
+ attr_reader :raw, :type
17
+
18
+ def initialize(raw)
19
+ @raw = raw
20
+ @type = raw["type"]&.to_sym
21
+ end
22
+
23
+ def terminal?
24
+ TERMINAL_TYPES.include?(@type)
25
+ end
26
+
27
+ def [](key)
28
+ @raw[key.to_s]
29
+ end
30
+
31
+ # Common shorthand for streaming text deltas.
32
+ # `message_update` with `assistantMessageEvent.type == "text_delta"`.
33
+ def delta
34
+ assistant_event&.[]("delta")
35
+ end
36
+
37
+ # True for an `extension_error` event, or a `message_update` whose
38
+ # assistant event is an error (agent turn errored or was aborted).
39
+ def error?
40
+ @type == :extension_error || assistant_event_type == :error
41
+ end
42
+
43
+ # Best-effort error text for an error event; nil if not an error.
44
+ def error_message
45
+ return @raw["error"] if @type == :extension_error
46
+ return nil unless assistant_event_type == :error
47
+
48
+ assistant_event["error"] || assistant_event["message"]
49
+ end
50
+
51
+ # Reason for an assistant-event error: "aborted" or "error". nil
52
+ # otherwise. Use this to distinguish a user abort from a real failure.
53
+ def error_reason
54
+ return nil unless assistant_event_type == :error
55
+
56
+ assistant_event["reason"]
57
+ end
58
+
59
+ def to_h
60
+ @raw
61
+ end
62
+
63
+ def inspect
64
+ "#<#{self.class.name} type=#{@type.inspect}>"
65
+ end
66
+
67
+ private
68
+
69
+ def assistant_event
70
+ ev = @raw["assistantMessageEvent"]
71
+ ev.is_a?(Hash) ? ev : nil
72
+ end
73
+
74
+ def assistant_event_type
75
+ assistant_event&.[]("type")&.to_sym
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,103 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PiAgent
4
+ # Handles the bidirectional Extension UI sub-protocol.
5
+ #
6
+ # pi extensions can request user interaction (`ctx.ui.select`,
7
+ # `ctx.ui.confirm`, ...). In RPC mode these arrive as
8
+ # `extension_ui_request` messages. Dialog methods block the agent until
9
+ # the client sends a matching `extension_ui_response`; fire-and-forget
10
+ # methods expect no response.
11
+ #
12
+ # Each request is handled on its own thread so a slow or blocking
13
+ # handler never stalls the transport reader thread (and therefore the
14
+ # agent event stream).
15
+ #
16
+ # The handler is a callable taking a Request and returning:
17
+ # - select / input / editor : a String value, or nil to cancel
18
+ # - confirm : true / false, or nil to cancel
19
+ # - fire-and-forget methods : return value ignored
20
+ #
21
+ # With no handler, dialogs are auto-cancelled so the agent never hangs.
22
+ class ExtensionUI
23
+ DIALOG_METHODS = %i[select confirm input editor].freeze
24
+
25
+ # One extension UI request. Wraps the raw protocol message.
26
+ class Request
27
+ attr_reader :id, :method, :raw
28
+
29
+ def initialize(raw)
30
+ @raw = raw
31
+ @id = raw["id"]
32
+ @method = raw["method"]&.to_sym
33
+ end
34
+
35
+ def dialog?
36
+ DIALOG_METHODS.include?(@method)
37
+ end
38
+
39
+ def title = @raw["title"]
40
+ def message = @raw["message"]
41
+ def options = @raw["options"]
42
+ def placeholder = @raw["placeholder"]
43
+ def prefill = @raw["prefill"]
44
+ def timeout_ms = @raw["timeout"]
45
+ def notify_type = @raw["notifyType"]
46
+ def text = @raw["text"]
47
+
48
+ def [](key)
49
+ @raw[key.to_s]
50
+ end
51
+ end
52
+
53
+ def initialize(writer:, handler: nil)
54
+ @writer = writer
55
+ @handler = handler
56
+ @threads = []
57
+ @mutex = Mutex.new
58
+ end
59
+
60
+ # Route an `extension_ui_request` message. Non-blocking: spawns a
61
+ # thread to run the handler and (for dialogs) send the response.
62
+ def dispatch(msg)
63
+ request = Request.new(msg)
64
+ @mutex.synchronize do
65
+ @threads.select!(&:alive?)
66
+ @threads << Thread.new { handle(request) }
67
+ end
68
+ end
69
+
70
+ # Wait for in-flight handler threads to finish (each up to `timeout`s).
71
+ def shutdown(timeout: 5)
72
+ @mutex.synchronize { @threads.dup }.each { |t| t.join(timeout) }
73
+ end
74
+
75
+ private
76
+
77
+ def handle(request)
78
+ result = invoke_handler(request)
79
+ return unless request.dialog?
80
+
81
+ @writer.write(response_for(request, result))
82
+ rescue ProtocolError
83
+ # Transport closed during shutdown; the response is moot.
84
+ end
85
+
86
+ def invoke_handler(request)
87
+ return nil if @handler.nil?
88
+
89
+ @handler.call(request)
90
+ rescue StandardError
91
+ # A raising handler cancels the dialog rather than hanging the agent.
92
+ nil
93
+ end
94
+
95
+ def response_for(request, result)
96
+ base = { type: "extension_ui_response", id: request.id }
97
+ return base.merge(cancelled: true) if result.nil?
98
+ return base.merge(confirmed: result ? true : false) if request.method == :confirm
99
+
100
+ base.merge(value: result.to_s)
101
+ end
102
+ end
103
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PiAgent
4
+ # Strict LF-only line framer for the pi RPC protocol.
5
+ #
6
+ # The protocol explicitly forbids generic line readers (Ruby `readline`,
7
+ # Node `readline`) because they also split on U+2028 / U+2029, which are
8
+ # valid inside JSON strings.
9
+ #
10
+ # Feed bytes; yield complete lines with any trailing CR stripped. Empty
11
+ # lines are dropped (the protocol uses single LFs between records).
12
+ class Framer
13
+ LF = "\n"
14
+
15
+ def initialize
16
+ @buffer = String.new(encoding: Encoding::BINARY)
17
+ end
18
+
19
+ def feed(bytes)
20
+ @buffer << bytes.b
21
+ while (idx = @buffer.index(LF))
22
+ line = @buffer.byteslice(0, idx)
23
+ @buffer = @buffer.byteslice(idx + 1, @buffer.bytesize - idx - 1) || String.new(encoding: Encoding::BINARY)
24
+ line.chomp!("\r")
25
+ next if line.empty?
26
+
27
+ yield line.force_encoding(Encoding::UTF_8)
28
+ end
29
+ end
30
+
31
+ def buffered?
32
+ !@buffer.empty?
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "monitor"
4
+
5
+ module PiAgent
6
+ # Thread-safe single-shot promise. Used to correlate RPC requests with
7
+ # their responses across the transport's stdout reader thread and the
8
+ # caller's thread.
9
+ class Future
10
+ def initialize
11
+ @mon = Monitor.new
12
+ @cond = @mon.new_cond
13
+ @resolved = false
14
+ @value = nil
15
+ @error = nil
16
+ end
17
+
18
+ def resolve(value)
19
+ @mon.synchronize do
20
+ return if @resolved
21
+
22
+ @value = value
23
+ @resolved = true
24
+ @cond.broadcast
25
+ end
26
+ end
27
+
28
+ def reject(error)
29
+ raise ArgumentError, "error must be an Exception" unless error.is_a?(Exception)
30
+
31
+ @mon.synchronize do
32
+ return if @resolved
33
+
34
+ @error = error
35
+ @resolved = true
36
+ @cond.broadcast
37
+ end
38
+ end
39
+
40
+ def value!(timeout: nil)
41
+ @mon.synchronize do
42
+ unless @resolved
43
+ @cond.wait(timeout)
44
+ raise PiAgent::TimeoutError, "Future timed out after #{timeout}s" unless @resolved
45
+ end
46
+ raise @error if @error
47
+
48
+ @value
49
+ end
50
+ end
51
+
52
+ def resolved?
53
+ @mon.synchronize { @resolved }
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "base64"
4
+
5
+ module PiAgent
6
+ # An image attachment for a prompt/steer/follow_up message.
7
+ #
8
+ # Serializes to the pi RPC ImageContent shape:
9
+ # { "type" => "image", "data" => <base64>, "mimeType" => <mime> }
10
+ #
11
+ # The pi prompt commands accept image attachments only; other file
12
+ # types are not part of the prompt wire protocol.
13
+ class Image
14
+ MIME_BY_EXTENSION = {
15
+ ".png" => "image/png",
16
+ ".jpg" => "image/jpeg",
17
+ ".jpeg" => "image/jpeg",
18
+ ".gif" => "image/gif",
19
+ ".webp" => "image/webp"
20
+ }.freeze
21
+
22
+ attr_reader :data, :mime_type
23
+
24
+ # Build from a file on disk. MIME type is inferred from the extension.
25
+ def self.from_file(path)
26
+ ext = File.extname(path).downcase
27
+ mime = MIME_BY_EXTENSION[ext]
28
+ raise ArgumentError, "Unsupported image extension #{ext.inspect} (#{path})" unless mime
29
+
30
+ from_bytes(File.binread(path), mime_type: mime)
31
+ end
32
+
33
+ # Build from raw binary image bytes.
34
+ def self.from_bytes(bytes, mime_type:)
35
+ new(data: Base64.strict_encode64(bytes), mime_type: mime_type)
36
+ end
37
+
38
+ # `data` must already be base64-encoded image data.
39
+ def initialize(data:, mime_type:)
40
+ @data = data
41
+ @mime_type = mime_type
42
+ end
43
+
44
+ def to_h
45
+ { "type" => "image", "data" => @data, "mimeType" => @mime_type }
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,236 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PiAgent
4
+ # High-level agent session. Wraps a Client and exposes prompt-flow
5
+ # ergonomics: submit a prompt and iterate the resulting event stream.
6
+ #
7
+ # PiAgent.session do |session|
8
+ # session.prompt("Write a haiku").each do |event|
9
+ # print event.delta if event.type == :message_update
10
+ # end
11
+ # end
12
+ #
13
+ # A pi RPC process hosts exactly one session, so there is no
14
+ # create/select step — the Session *is* the running pi process.
15
+ #
16
+ # v1 limitation: `prompt` streams one agent cycle (agent_start..agent_end).
17
+ # Messages queued mid-flight via `follow_up`/`steer` run in subsequent
18
+ # cycles; consume those by calling `prompt`-less `events` or another
19
+ # `prompt`. Bidirectional extension UI is not yet surfaced here.
20
+ class Session
21
+ # Max time to wait for the next event before assuming the agent stalled.
22
+ DEFAULT_EVENT_TIMEOUT = 300
23
+ # Max time to wait for a command to be acknowledged.
24
+ DEFAULT_ACK_TIMEOUT = 30
25
+
26
+ attr_reader :client
27
+
28
+ def initialize(client)
29
+ @client = client
30
+ end
31
+
32
+ # Submit a user prompt. With a block, yields each Event until the
33
+ # agent finishes (agent_end), then returns self. Without a block,
34
+ # returns an Enumerator of Events.
35
+ #
36
+ # `images` accepts PiAgent::Image objects, file path strings, or
37
+ # raw ImageContent hashes — in any mix.
38
+ def prompt(message, images: nil, event_timeout: DEFAULT_EVENT_TIMEOUT, &block)
39
+ stream = event_stream("prompt", message_params(message, images), event_timeout: event_timeout)
40
+
41
+ return stream unless block
42
+
43
+ stream.each(&block)
44
+ self
45
+ end
46
+
47
+ # Queue a steering message while the agent is running. Delivered after
48
+ # the current assistant turn finishes its tool calls, before the next
49
+ # LLM call. Fire-and-forget; raises on rejection.
50
+ def steer(message, images: nil)
51
+ @client.request("steer", message_params(message, images)).value!(timeout: DEFAULT_ACK_TIMEOUT)
52
+ self
53
+ end
54
+
55
+ # Queue a follow-up message, delivered only after the agent stops.
56
+ def follow_up(message, images: nil)
57
+ @client.request("follow_up", message_params(message, images)).value!(timeout: DEFAULT_ACK_TIMEOUT)
58
+ self
59
+ end
60
+
61
+ # Single-shot helper mirroring pi's print mode: submit `message`,
62
+ # drain the whole event stream, and return the final assistant text
63
+ # (nil if the agent produced none). Yields each Event to an optional
64
+ # block while the stream drains.
65
+ def run(message, images: nil, event_timeout: DEFAULT_EVENT_TIMEOUT)
66
+ prompt(message, images: images, event_timeout: event_timeout) do |event|
67
+ yield event if block_given?
68
+ end
69
+ last_assistant_text
70
+ end
71
+
72
+ # Abort the current agent run. Fire-and-forget.
73
+ def abort
74
+ @client.notify("abort")
75
+ self
76
+ end
77
+
78
+ # Switch to a specific model. Accepts either a single "provider/modelId"
79
+ # string or the two parts as separate arguments.
80
+ def set_model(provider, model_id = nil)
81
+ provider, model_id = provider.split("/", 2) if model_id.nil?
82
+ @client.request("set_model", provider: provider, modelId: model_id)
83
+ .value!(timeout: DEFAULT_ACK_TIMEOUT)
84
+ self
85
+ end
86
+
87
+ # Switch to the next configured model. Returns the new
88
+ # { "model" =>, "thinkingLevel" =>, "isScoped" => } hash, or {} when
89
+ # only one model is available.
90
+ def cycle_model
91
+ request_data("cycle_model")
92
+ end
93
+
94
+ # All configured models, as an array of Model hashes.
95
+ def available_models
96
+ request_data("get_available_models").fetch("models", [])
97
+ end
98
+
99
+ # Set the reasoning level: "off", "minimal", "low", "medium", "high",
100
+ # or "xhigh" (xhigh is OpenAI codex-max only).
101
+ def set_thinking(level)
102
+ @client.request("set_thinking_level", level: level).value!(timeout: DEFAULT_ACK_TIMEOUT)
103
+ self
104
+ end
105
+
106
+ def get_state
107
+ @client.request("get_state").value!(timeout: DEFAULT_ACK_TIMEOUT)
108
+ end
109
+
110
+ # Full conversation history, as an array of AgentMessage hashes.
111
+ def messages
112
+ request_data("get_messages").fetch("messages", [])
113
+ end
114
+
115
+ # Text of the last assistant message, or nil if there is none.
116
+ def last_assistant_text
117
+ request_data("get_last_assistant_text")["text"]
118
+ end
119
+
120
+ # Manually compact the conversation context to reduce token usage.
121
+ # Returns the result hash ({ "summary" =>, "firstKeptEntryId" =>,
122
+ # "tokensBefore" => }).
123
+ def compact(custom_instructions: nil)
124
+ params = {}
125
+ params[:customInstructions] = custom_instructions if custom_instructions
126
+ request_data("compact", params)
127
+ end
128
+
129
+ # Start a fresh session in the same pi process. Pass `parent_session:`
130
+ # (a session file path) to record provenance. Returns
131
+ # { "cancelled" => bool }; cancelled is true if an extension vetoed it.
132
+ def new_session(parent_session: nil)
133
+ params = {}
134
+ params[:parentSession] = parent_session if parent_session
135
+ request_data("new_session", params)
136
+ end
137
+
138
+ # Load a different session file into this process. Returns
139
+ # { "cancelled" => bool }; cancelled is true if an extension vetoed it.
140
+ def switch_session(path)
141
+ request_data("switch_session", sessionPath: path)
142
+ end
143
+
144
+ # Token usage, cost, and context-window stats for the current session.
145
+ # Returns the data hash, including "sessionId" and "sessionFile".
146
+ def session_stats
147
+ request_data("get_session_stats")
148
+ end
149
+
150
+ # List user messages available for forking. Returns an array of
151
+ # { "entryId" => ..., "text" => ... } hashes.
152
+ def fork_messages
153
+ request_data("get_fork_messages").fetch("messages", [])
154
+ end
155
+
156
+ # Fork a new branch from a previous user message (an entryId from
157
+ # `fork_messages`). Returns { "text" => <forked-from text>,
158
+ # "cancelled" => bool }; `cancelled` is true if an extension vetoed it.
159
+ def fork(entry_id)
160
+ request_data("fork", entryId: entry_id)
161
+ end
162
+
163
+ # Duplicate the current active branch into a new session at the
164
+ # current position. Returns { "cancelled" => bool }. Maps to the
165
+ # `clone` RPC command (named `clone_session` to avoid shadowing
166
+ # Object#clone).
167
+ def clone_session
168
+ request_data("clone")
169
+ end
170
+
171
+ def set_session_name(name)
172
+ @client.request("set_session_name", name: name).value!(timeout: DEFAULT_ACK_TIMEOUT)
173
+ self
174
+ end
175
+
176
+ def close
177
+ @client.close
178
+ end
179
+
180
+ private
181
+
182
+ # Build the { message:, images? } params for a prompt-style command.
183
+ def message_params(message, images)
184
+ params = { message: message }
185
+ normalized = normalize_images(images)
186
+ params[:images] = normalized unless normalized.empty?
187
+ params
188
+ end
189
+
190
+ # Coerce mixed image inputs into ImageContent hashes.
191
+ def normalize_images(images)
192
+ Array(images).map do |image|
193
+ case image
194
+ when Image then image.to_h
195
+ when String then Image.from_file(image).to_h
196
+ when Hash then image
197
+ else raise ArgumentError, "Unsupported image: #{image.inspect}"
198
+ end
199
+ end
200
+ end
201
+
202
+ # Send a request and return its `data` payload (the part RPC commands
203
+ # like fork/clone/get_fork_messages carry their result in).
204
+ def request_data(type, params = {})
205
+ response = @client.request(type, params).value!(timeout: DEFAULT_ACK_TIMEOUT)
206
+ response["data"] || {}
207
+ end
208
+
209
+ # Subscribe, send the command, then yield Events from the notification
210
+ # stream until a terminal event. The subscription is scoped to one
211
+ # iteration of the returned Enumerator so cleanup is deterministic.
212
+ def event_stream(type, params, event_timeout:)
213
+ Enumerator.new do |yielder|
214
+ queue = Queue.new
215
+ handle = @client.subscribe { |msg| queue << msg }
216
+ begin
217
+ @client.request(type, params).value!(timeout: DEFAULT_ACK_TIMEOUT)
218
+ pump_events(queue, yielder, event_timeout)
219
+ ensure
220
+ @client.unsubscribe(handle)
221
+ end
222
+ end
223
+ end
224
+
225
+ def pump_events(queue, yielder, event_timeout)
226
+ loop do
227
+ msg = queue.pop(timeout: event_timeout)
228
+ raise TimeoutError, "No event received within #{event_timeout}s" if msg.nil?
229
+
230
+ event = Event.new(msg)
231
+ yielder << event
232
+ break if event.terminal?
233
+ end
234
+ end
235
+ end
236
+ end
@@ -0,0 +1,140 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+ require "json"
5
+
6
+ module PiAgent
7
+ module Transport
8
+ # Runs `pi --mode rpc` as a local child process and speaks NDJSON
9
+ # over its stdio.
10
+ #
11
+ # One reader thread per pipe; stdout lines are JSON-parsed and
12
+ # dispatched to `on_message`; stderr lines are forwarded raw to
13
+ # `on_stderr`. Writes are serialized through a mutex so concurrent
14
+ # senders don't interleave JSON payloads on stdin.
15
+ class Subprocess
16
+ DEFAULT_CHUNK_SIZE = 4096
17
+ DEFAULT_CLOSE_TIMEOUT = 5
18
+
19
+ attr_reader :pid
20
+
21
+ # `cwd` sets the child's working directory — pi's built-in tools
22
+ # (bash/read/edit/...) operate relative to it. nil leaves the
23
+ # child in this process's working directory.
24
+ def initialize(command:, env: {}, cwd: nil, on_message: nil, on_stderr: nil)
25
+ @command = Array(command)
26
+ @env = env.transform_keys(&:to_s)
27
+ @cwd = cwd
28
+ @on_message = on_message
29
+ @on_stderr = on_stderr
30
+ @write_mutex = Mutex.new
31
+ @closed = false
32
+ end
33
+
34
+ def start
35
+ @stdin, @stdout, @stderr, @wait_thr = Open3.popen3(*spawn_args)
36
+ @pid = @wait_thr.pid
37
+ @stdin.binmode
38
+ @stdout.binmode
39
+ @stderr.binmode
40
+ @stdout_thread = Thread.new { read_loop(@stdout, :stdout) }
41
+ @stderr_thread = Thread.new { read_loop(@stderr, :stderr) }
42
+ self
43
+ end
44
+
45
+ def write(obj)
46
+ payload = "#{JSON.generate(obj)}\n"
47
+ @write_mutex.synchronize do
48
+ raise ProtocolError, "Transport closed" if @closed
49
+
50
+ @stdin.write(payload)
51
+ @stdin.flush
52
+ end
53
+ rescue Errno::EPIPE
54
+ raise ProtocolError, "Broken pipe writing to subprocess (process may have exited)"
55
+ end
56
+
57
+ def close(timeout: DEFAULT_CLOSE_TIMEOUT)
58
+ return if mark_closed!
59
+
60
+ safe_close(@stdin)
61
+ wait_for_exit(timeout)
62
+ [@stdout_thread, @stderr_thread].compact.each(&:join)
63
+ safe_close(@stdout)
64
+ safe_close(@stderr)
65
+ end
66
+
67
+ def alive?
68
+ !!@wait_thr&.alive?
69
+ end
70
+
71
+ def exit_status
72
+ @wait_thr&.value
73
+ end
74
+
75
+ private
76
+
77
+ def spawn_args
78
+ args = [@env, *@command]
79
+ args << { chdir: @cwd.to_s } if @cwd
80
+ args
81
+ end
82
+
83
+ def mark_closed!
84
+ @write_mutex.synchronize do
85
+ return true if @closed
86
+
87
+ @closed = true
88
+ false
89
+ end
90
+ end
91
+
92
+ def safe_close(io)
93
+ io&.close
94
+ rescue IOError
95
+ # already closed
96
+ end
97
+
98
+ def wait_for_exit(timeout)
99
+ return if @wait_thr&.join(timeout)
100
+
101
+ terminate_process
102
+ return if @wait_thr&.join(2)
103
+
104
+ kill_process
105
+ @wait_thr&.join
106
+ end
107
+
108
+ def read_loop(io, channel)
109
+ framer = Framer.new
110
+ loop do
111
+ chunk = io.readpartial(DEFAULT_CHUNK_SIZE)
112
+ framer.feed(chunk) do |line|
113
+ channel == :stdout ? dispatch_stdout(line) : @on_stderr&.call(line)
114
+ end
115
+ end
116
+ rescue IOError, Errno::EBADF
117
+ # Pipe closed; reader exits normally. (EOFError descends from IOError.)
118
+ end
119
+
120
+ def dispatch_stdout(line)
121
+ msg = JSON.parse(line)
122
+ @on_message&.call(msg)
123
+ rescue JSON::ParserError => e
124
+ @on_stderr&.call("[pi-agent-rb] invalid JSON on stdout: #{e.message}: #{line.inspect}")
125
+ end
126
+
127
+ def terminate_process
128
+ Process.kill("TERM", @pid)
129
+ rescue Errno::ESRCH, Errno::EPERM
130
+ # Process already gone
131
+ end
132
+
133
+ def kill_process
134
+ Process.kill("KILL", @pid)
135
+ rescue Errno::ESRCH, Errno::EPERM
136
+ # Process already gone
137
+ end
138
+ end
139
+ end
140
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PiAgent
4
+ # A transport carries the pi RPC protocol between this process and a
5
+ # running `pi --mode rpc`. It abstracts *where* pi runs: a local
6
+ # subprocess (Transport::Subprocess), or — via a caller-supplied
7
+ # transport — a process inside a remote sandbox.
8
+ #
9
+ # Contract. A transport is constructed with two callables:
10
+ #
11
+ # on_message: ->(Hash) # one parsed JSON message from pi's stdout
12
+ # on_stderr: ->(String) # one line from pi's stderr
13
+ #
14
+ # and responds to:
15
+ #
16
+ # #start -> self; begin delivering messages
17
+ # #write(Hash) -> serialize to a JSON line, send to pi's stdin
18
+ # #close(timeout:) -> shut pi down
19
+ # #alive? -> Boolean
20
+ #
21
+ # Implementations own framing (pi speaks strict-LF JSONL — see Framer)
22
+ # and the thread-safety of #write. Client injects its own handlers via
23
+ # a transport factory, so transports never need settable callbacks.
24
+ module Transport
25
+ end
26
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PiAgent
4
+ VERSION = "0.1.0"
5
+
6
+ # Pinned upstream pi-coding-agent version this gem is verified against.
7
+ # See: https://www.npmjs.com/package/@earendil-works/pi-coding-agent
8
+ SUPPORTED_PI_VERSION = "0.75.3"
9
+ end
data/lib/pi_agent.rb ADDED
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "pi_agent/version"
4
+ require_relative "pi_agent/errors"
5
+ require_relative "pi_agent/framer"
6
+ require_relative "pi_agent/future"
7
+ require_relative "pi_agent/transport"
8
+ require_relative "pi_agent/transport/subprocess"
9
+ require_relative "pi_agent/extension_ui"
10
+ require_relative "pi_agent/client"
11
+ require_relative "pi_agent/event"
12
+ require_relative "pi_agent/image"
13
+ require_relative "pi_agent/session"
14
+
15
+ module PiAgent
16
+ # Open a low-level RPC client (spawns `pi --mode rpc`).
17
+ def self.open(**)
18
+ client = Client.new(**).start
19
+ return client unless block_given?
20
+
21
+ begin
22
+ yield client
23
+ ensure
24
+ client.close
25
+ end
26
+ end
27
+
28
+ # Open a high-level agent session. This is the common entrypoint.
29
+ def self.session(**)
30
+ client = Client.new(**).start
31
+ session = Session.new(client)
32
+ return session unless block_given?
33
+
34
+ begin
35
+ yield session
36
+ ensure
37
+ session.close
38
+ end
39
+ end
40
+ end
metadata ADDED
@@ -0,0 +1,91 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: pi-agent-rb
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - chagel
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: async
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '2.0'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '2.0'
26
+ - !ruby/object:Gem::Dependency
27
+ name: base64
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - "~>"
31
+ - !ruby/object:Gem::Version
32
+ version: '0.2'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: '0.2'
40
+ description: |
41
+ Ruby client for `@earendil-works/pi-coding-agent`. Spawns the pi CLI in RPC
42
+ mode and speaks its JSONL protocol from Ruby. Designed for building
43
+ interactive agent UIs (web, TUI) on top of pi.
44
+
45
+ Not officially maintained by the pi project.
46
+ email: []
47
+ executables: []
48
+ extensions: []
49
+ extra_rdoc_files: []
50
+ files:
51
+ - CHANGELOG.md
52
+ - LICENSE
53
+ - README.md
54
+ - lib/pi_agent.rb
55
+ - lib/pi_agent/client.rb
56
+ - lib/pi_agent/errors.rb
57
+ - lib/pi_agent/event.rb
58
+ - lib/pi_agent/extension_ui.rb
59
+ - lib/pi_agent/framer.rb
60
+ - lib/pi_agent/future.rb
61
+ - lib/pi_agent/image.rb
62
+ - lib/pi_agent/session.rb
63
+ - lib/pi_agent/transport.rb
64
+ - lib/pi_agent/transport/subprocess.rb
65
+ - lib/pi_agent/version.rb
66
+ homepage: https://github.com/chagel/pi-agent-rb
67
+ licenses:
68
+ - MIT
69
+ metadata:
70
+ homepage_uri: https://github.com/chagel/pi-agent-rb
71
+ source_code_uri: https://github.com/chagel/pi-agent-rb
72
+ changelog_uri: https://github.com/chagel/pi-agent-rb/blob/main/CHANGELOG.md
73
+ rubygems_mfa_required: 'true'
74
+ rdoc_options: []
75
+ require_paths:
76
+ - lib
77
+ required_ruby_version: !ruby/object:Gem::Requirement
78
+ requirements:
79
+ - - ">="
80
+ - !ruby/object:Gem::Version
81
+ version: 3.2.0
82
+ required_rubygems_version: !ruby/object:Gem::Requirement
83
+ requirements:
84
+ - - ">="
85
+ - !ruby/object:Gem::Version
86
+ version: '0'
87
+ requirements: []
88
+ rubygems_version: 4.0.10
89
+ specification_version: 4
90
+ summary: Ruby client for the pi coding agent (drives `pi --mode rpc`)
91
+ test_files: []