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 +7 -0
- data/CHANGELOG.md +22 -0
- data/LICENSE +21 -0
- data/README.md +151 -0
- data/lib/pi_agent/client.rb +170 -0
- data/lib/pi_agent/errors.rb +22 -0
- data/lib/pi_agent/event.rb +78 -0
- data/lib/pi_agent/extension_ui.rb +103 -0
- data/lib/pi_agent/framer.rb +35 -0
- data/lib/pi_agent/future.rb +56 -0
- data/lib/pi_agent/image.rb +48 -0
- data/lib/pi_agent/session.rb +236 -0
- data/lib/pi_agent/transport/subprocess.rb +140 -0
- data/lib/pi_agent/transport.rb +26 -0
- data/lib/pi_agent/version.rb +9 -0
- data/lib/pi_agent.rb +40 -0
- metadata +91 -0
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
|
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: []
|