pi-agent-rb 0.2.2 → 0.3.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: db7831746ed3cbbd46e215293bb6a6e34dff2e31915b47a733737aa91ae15e4b
4
- data.tar.gz: 61af774b3c9125f05aa4cfcafe11e5af31459a7db8a796487ed8857f5b677c1c
3
+ metadata.gz: ec081bcb7d1b2bac67301f37239e002e9b32d0eeb065d80a36b4d217896e1485
4
+ data.tar.gz: f19a72e0adb62fb42a49e3a21f2bcdffe3d46b3b546752c66e36427469c04c6e
5
5
  SHA512:
6
- metadata.gz: cb4424e7698e4481dbda464cd5df382a3a555316ee923d5ca5d55faf9692701b606932d753e13ebf5dacf94c878868c7455e401786e1b5667f0d8daaccbf5c15
7
- data.tar.gz: ccc703e5e81c2bf3ff3b47fa2605a391eeea66e9f1cee7603fba83db2e74f948a7c4b14b90db79ade99a5d8024513619c8764595a9d9456a8af6ede8b34815d1
6
+ metadata.gz: 1493e49939b44a45ff13fa7652b855ff48e25c611ad59bb6ac00c725bec549e76297156a455b20668bc5641a7fb0d8bcadd3eb535227e4e988c06c004a34dd92
7
+ data.tar.gz: 9cbd030aa9ae76199aa5730aab067bfd447a15416e9845d17fa3632f8baffd341586d0ca11b423eb4b66da6baa837c92511ec2ad7a6703a85c40e8d5996396d4
data/CHANGELOG.md CHANGED
@@ -7,6 +7,35 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.3.0] - 2026-07-31
11
+
12
+ ### Added
13
+ - Transport death notification. When pi dies unexpectedly (OOM kill,
14
+ missing binary after spawn, sandbox teardown), the client now learns
15
+ immediately instead of waiting out the 30s ack / 300s event timeouts
16
+ with a generic `TimeoutError`:
17
+ - Transports may accept an `on_close:` callable alongside
18
+ `on_message:`/`on_stderr:` and invoke it exactly once, with a short
19
+ human-readable reason, when they reach a terminal state on their
20
+ own — never for a caller-initiated `#close`. `Transport::Subprocess`
21
+ implements this (reporting exit status or signal, and watching the
22
+ child process itself so a descendant holding the stdout pipe open
23
+ can't defer the notification). The contract is documented in
24
+ `Transport` and the README's "Custom transports" section.
25
+ - On notification, `Client` rejects in-flight request futures with the
26
+ new `PiAgent::TransportClosedError` (a `ProtocolError` subclass with
27
+ the reason on `#reason`) and wakes subscribers with a synthetic
28
+ `Client::TRANSPORT_CLOSED_TYPE` message, which `Session` event
29
+ streams re-raise as `TransportClosedError`. Later `request`/`notify`
30
+ calls fail fast with the same error, and subscribers registered
31
+ after the death get the notification replayed once.
32
+ - Backward compatible: `Client#start` passes `on_close:` only when the
33
+ factory accepts the keyword (or `**kwargs`); existing
34
+ `(on_message:, on_stderr:)` factories keep their previous
35
+ timeout-backstop behavior unchanged.
36
+ - Subscriber callbacks are isolated during fanout: one raising
37
+ subscriber no longer prevents the rest from receiving a message.
38
+
10
39
  ## [0.2.2] - 2026-07-30
11
40
 
12
41
  ### Added
data/README.md CHANGED
@@ -220,10 +220,60 @@ end
220
220
  a pi extension vetoes the operation — that is an expected outcome, not
221
221
  an error.
222
222
 
223
+ ## Custom transports
224
+
225
+ By default the client spawns `pi --mode rpc` as a local subprocess. Pass
226
+ `transport_factory:` to run pi somewhere else — a container, a remote
227
+ sandbox:
228
+
229
+ ```ruby
230
+ factory = lambda do |on_message:, on_stderr:, on_close:|
231
+ MySandboxTransport.new(
232
+ sandbox: sandbox,
233
+ on_message: on_message, on_stderr: on_stderr, on_close: on_close
234
+ )
235
+ end
236
+
237
+ PiAgent.session(transport_factory: factory) do |session|
238
+ # pi runs inside the sandbox; the protocol flows through your transport
239
+ end
240
+ ```
241
+
242
+ The factory receives the client's handlers and returns an object
243
+ implementing `#start`, `#write(Hash)`, `#close(timeout:)`, and `#alive?`.
244
+ [`Transport`](lib/pi_agent/transport.rb) documents the full contract;
245
+ [`Transport::Subprocess`](lib/pi_agent/transport/subprocess.rb) is the
246
+ reference implementation.
247
+
248
+ `on_close:` is the transport's death notification. Invoke it exactly
249
+ once, with a short human-readable reason (e.g.
250
+ `"process terminated by signal 9"`), when the transport reaches a
251
+ terminal state *on its own* — process exit, read-stream EOF, fatal stream
252
+ error — and only after delivering any stdout messages already read. Do
253
+ not invoke it for a shutdown initiated through `#close`. When it fires,
254
+ the client fails in-flight requests and live event streams promptly with
255
+ `PiAgent::TransportClosedError` (reason on `#reason`) instead of letting
256
+ them wait out the 30s ack / 300s event timeouts, and later
257
+ `request`/`notify` calls fail fast with the same error. Subscribers that
258
+ register after the death get the notification replayed once, so an event
259
+ stream started late still ends promptly.
260
+
261
+ `on_close:` is optional and backward compatible: the client inspects the
262
+ factory's parameters and passes the keyword only when the factory accepts
263
+ it (explicitly or via `**kwargs`). An existing
264
+ `(on_message:, on_stderr:)` factory keeps working unchanged — the
265
+ timeouts then remain the only backstop when pi dies.
266
+
223
267
  ## Errors
224
268
 
225
269
  - A failed RPC command (`success: false`) raises `PiAgent::CommandError`,
226
270
  which carries the failing `#command` name.
271
+ - If the transport dies out from under the client (pi OOM-killed, sandbox
272
+ torn down), in-flight requests and event streams raise
273
+ `PiAgent::TransportClosedError` promptly, with the death reason on
274
+ `#reason`. A caller-initiated `close` never raises it. This requires
275
+ the transport to report death — the bundled subprocess transport does;
276
+ for custom transports see [Custom transports](#custom-transports).
227
277
  - Agent-side errors arrive *in* the event stream, not as exceptions —
228
278
  inspect them with `Event#error?`, `#error_message`, and `#error_reason`
229
279
  (`"aborted"` vs `"error"`). This covers `extension_error` events and
@@ -14,7 +14,10 @@ module PiAgent
14
14
  # By default the client spawns `pi --mode rpc` as a local subprocess.
15
15
  # Pass `transport_factory:` — a callable `(on_message:, on_stderr:) ->
16
16
  # transport` — to run pi somewhere else (e.g. inside a remote sandbox).
17
- # See Transport for the transport contract.
17
+ # A factory may additionally accept `on_close:` to report transport
18
+ # death; the keyword is passed only when the factory accepts it, so
19
+ # older two-keyword factories keep working. See Transport for the
20
+ # transport contract.
18
21
  #
19
22
  # Since pi 0.79.0 project-local inputs (.pi/settings.json, project
20
23
  # extensions, resources, packages) are trust-gated, and in RPC mode pi
@@ -25,6 +28,12 @@ module PiAgent
25
28
  DEFAULT_BIN = "pi"
26
29
  DEFAULT_ARGS = ["--mode", "rpc"].freeze
27
30
 
31
+ # Synthetic message fanned out to subscribers when the transport dies,
32
+ # so event streams wake promptly instead of waiting out their timeouts.
33
+ # Never sent by pi (the "pi_agent/" prefix keeps it out of upstream's
34
+ # event namespace); carries the death reason under "reason".
35
+ TRANSPORT_CLOSED_TYPE = "pi_agent/transport_closed"
36
+
28
37
  attr_reader :bin
29
38
 
30
39
  def self.resolve_bin(override = nil)
@@ -64,13 +73,15 @@ module PiAgent
64
73
  @subscribers_mutex = Mutex.new
65
74
  @transport = nil
66
75
  @extension_ui = nil
76
+ # Close/death bookkeeping, guarded by @pending_mutex except
77
+ # @close_broadcast (@subscribers_mutex).
78
+ @caller_closed = false
79
+ @close_reason = nil
80
+ @close_broadcast = nil
67
81
  end
68
82
 
69
83
  def start
70
- @transport = @transport_factory.call(
71
- on_message: method(:handle_message),
72
- on_stderr: method(:handle_stderr)
73
- )
84
+ @transport = @transport_factory.call(**transport_callbacks)
74
85
  @extension_ui = ExtensionUI.new(
75
86
  writer: @transport,
76
87
  handler: @extension_ui_handler,
@@ -83,21 +94,44 @@ module PiAgent
83
94
  def request(type, params = {})
84
95
  id = next_id
85
96
  future = Future.new
86
- @pending_mutex.synchronize { @pending[id] = future }
97
+ @pending_mutex.synchronize do
98
+ raise TransportClosedError, @close_reason if @close_reason
99
+
100
+ @pending[id] = future
101
+ end
87
102
  payload = { id: id, type: type }.merge(params)
88
- @transport.write(payload)
103
+ begin
104
+ @transport.write(payload)
105
+ rescue StandardError => e
106
+ raise abandon_request(id, e)
107
+ end
89
108
  future
90
109
  end
91
110
 
92
111
  def notify(type, params = {})
112
+ @pending_mutex.synchronize do
113
+ raise TransportClosedError, @close_reason if @close_reason
114
+ end
93
115
  payload = { type: type }.merge(params)
94
- @transport.write(payload)
116
+ begin
117
+ @transport.write(payload)
118
+ rescue StandardError => e
119
+ raise close_error_or(e)
120
+ end
95
121
  end
96
122
 
123
+ # Subscribers registered after a transport death still learn about it:
124
+ # the synthetic TRANSPORT_CLOSED_TYPE message is replayed to them once,
125
+ # outside any lock. (The death fanout snapshots subscribers atomically
126
+ # with recording the broadcast, so nobody sees it twice.)
97
127
  def subscribe(&block)
98
128
  raise ArgumentError, "subscribe requires a block" unless block
99
129
 
100
- @subscribers_mutex.synchronize { @subscribers << block }
130
+ replay = @subscribers_mutex.synchronize do
131
+ @subscribers << block
132
+ @close_broadcast
133
+ end
134
+ deliver([block], replay) if replay
101
135
  block
102
136
  end
103
137
 
@@ -106,6 +140,10 @@ module PiAgent
106
140
  end
107
141
 
108
142
  def close
143
+ # A caller-initiated close is a clean shutdown: mark it first so the
144
+ # transport teardown's own death notification is ignored and never
145
+ # surfaces as a TransportClosedError.
146
+ @pending_mutex.synchronize { @caller_closed = true }
109
147
  # Drain extension UI handler threads while the transport is still
110
148
  # open so their responses can still be written.
111
149
  @extension_ui&.shutdown
@@ -125,14 +163,42 @@ module PiAgent
125
163
  def build_subprocess_factory(bin, args, env, cwd)
126
164
  @bin = self.class.resolve_bin(bin)
127
165
  command = [@bin, *Array(args)]
128
- lambda do |on_message:, on_stderr:|
166
+ lambda do |on_message:, on_stderr:, on_close:|
129
167
  Transport::Subprocess.new(
130
168
  command: command, env: env, cwd: cwd,
131
- on_message: on_message, on_stderr: on_stderr
169
+ on_message: on_message, on_stderr: on_stderr, on_close: on_close
132
170
  )
133
171
  end
134
172
  end
135
173
 
174
+ def transport_callbacks
175
+ callbacks = {
176
+ on_message: method(:handle_message),
177
+ on_stderr: method(:handle_stderr)
178
+ }
179
+ callbacks[:on_close] = method(:handle_transport_close) if factory_accepts_on_close?
180
+ callbacks
181
+ end
182
+
183
+ # External factories predate on_close; pass it only to callables that
184
+ # declare the keyword (or **kwargs), so factories with the older
185
+ # `(on_message:, on_stderr:)` shape keep working unchanged.
186
+ def factory_accepts_on_close?
187
+ factory_parameters(@transport_factory).any? do |kind, name|
188
+ kind == :keyrest || (%i[keyreq key].include?(kind) && name == :on_close)
189
+ end
190
+ end
191
+
192
+ # Proc#parameters / Method#parameters describe the callable itself.
193
+ # Any other callable object may define an unrelated #parameters method,
194
+ # so inspect its #call method instead of trusting that name.
195
+ def factory_parameters(factory)
196
+ case factory
197
+ when Proc, Method then factory.parameters
198
+ else factory.method(:call).parameters
199
+ end
200
+ end
201
+
136
202
  def next_id
137
203
  @pending_mutex.synchronize do
138
204
  @next_id += 1
@@ -163,21 +229,76 @@ module PiAgent
163
229
  end
164
230
 
165
231
  def notify_subscribers(msg)
166
- callbacks = @subscribers_mutex.synchronize { @subscribers.dup }
167
- callbacks.each { |cb| cb.call(msg) }
232
+ deliver(@subscribers_mutex.synchronize { @subscribers.dup }, msg)
233
+ end
234
+
235
+ # Fan a message out, isolating each subscriber: one raising callback
236
+ # must not starve the rest (in particular, a raising logger must not
237
+ # keep a Session stream from seeing the death notification).
238
+ def deliver(callbacks, msg)
239
+ callbacks.each do |cb|
240
+ cb.call(msg)
241
+ rescue StandardError => e
242
+ handle_stderr("[pi-agent-rb] subscriber raised #{e.class}: #{e.message}")
243
+ end
168
244
  end
169
245
 
170
246
  def handle_stderr(line)
171
247
  # no-op by default; future versions may wire a logger
172
248
  end
173
249
 
174
- def reject_pending(error)
250
+ # Transport-side death (process exit, stream EOF, fatal stream error).
251
+ # Idempotent, and a no-op after a caller-initiated #close — a clean
252
+ # shutdown is not an error. Fails all pending futures and wakes
253
+ # subscribers with a TRANSPORT_CLOSED_TYPE message so event streams
254
+ # end promptly instead of waiting out their timeouts. The broadcast is
255
+ # recorded atomically with the subscriber snapshot, so subscribers
256
+ # registered later get it replayed exactly once (see #subscribe).
257
+ def handle_transport_close(reason)
175
258
  pending = @pending_mutex.synchronize do
176
- snapshot = @pending.values
177
- @pending.clear
178
- snapshot
259
+ return if @caller_closed || @close_reason
260
+
261
+ @close_reason = reason
262
+ take_pending
263
+ end
264
+ message = { "type" => TRANSPORT_CLOSED_TYPE, "reason" => reason }
265
+ callbacks = @subscribers_mutex.synchronize do
266
+ @close_broadcast = message
267
+ @subscribers.dup
179
268
  end
269
+ pending.each { |f| f.reject(TransportClosedError.new(reason)) }
270
+ deliver(callbacks, message)
271
+ end
272
+
273
+ def reject_pending(error)
274
+ pending = @pending_mutex.synchronize { take_pending }
180
275
  pending.each { |f| f.reject(error) }
181
276
  end
277
+
278
+ # Must be called holding @pending_mutex.
279
+ def take_pending
280
+ snapshot = @pending.values
281
+ @pending.clear
282
+ snapshot
283
+ end
284
+
285
+ # A write failed after the future was registered. Unregister it and,
286
+ # when the transport is dead, surface the death rather than the raw
287
+ # pipe error. Returns the error to raise; the future (if still ours)
288
+ # is rejected with the same error so it never dangles.
289
+ def abandon_request(id, error)
290
+ future = @pending_mutex.synchronize { @pending.delete(id) }
291
+ final = close_error_or(error)
292
+ future&.reject(final)
293
+ final
294
+ end
295
+
296
+ # Surface the death rather than the raw pipe error when the transport
297
+ # has already been reported dead. When the write error beats the
298
+ # notification, the raw error stands — it fails just as promptly.
299
+ def close_error_or(error)
300
+ reason = @pending_mutex.synchronize { @close_reason }
301
+ reason ? TransportClosedError.new(reason) : error
302
+ end
182
303
  end
183
304
  end
@@ -9,6 +9,21 @@ module PiAgent
9
9
  class SessionError < Error; end
10
10
  class TimeoutError < Error; end
11
11
 
12
+ # Raised when the transport dies out from under the client — child
13
+ # process exit, read-stream EOF, fatal stream error — while requests or
14
+ # event streams are outstanding, and on any `request`/`notify` attempted
15
+ # after the death. A caller-initiated `Client#close` never raises it.
16
+ # `#reason` carries the transport's short description of what happened
17
+ # (e.g. "process terminated by signal 9").
18
+ class TransportClosedError < ProtocolError
19
+ attr_reader :reason
20
+
21
+ def initialize(reason)
22
+ @reason = reason
23
+ super("Transport closed: #{reason}")
24
+ end
25
+ end
26
+
12
27
  # Raised when an RPC command returns `success: false`. Carries the
13
28
  # failing command name so callers can branch on it.
14
29
  class CommandError < Error
@@ -19,6 +19,10 @@ module PiAgent
19
19
  # submit it as a streaming-aware prompt and drain it race-free. `events` is
20
20
  # a prompt-less drain for when you have already subscribed before processing
21
21
  # starts.
22
+ #
23
+ # If the transport dies mid-stream (pi killed, sandbox torn down), event
24
+ # streams raise TransportClosedError promptly instead of waiting out the
25
+ # event timeout — provided the transport reports death (see Transport).
22
26
  class Session
23
27
  # Max time to wait for the next event before assuming the agent stalled.
24
28
  DEFAULT_EVENT_TIMEOUT = 300
@@ -322,6 +326,10 @@ module PiAgent
322
326
  loop do
323
327
  msg = queue.pop(timeout: event_timeout)
324
328
  raise TimeoutError, "No event received within #{event_timeout}s" if msg.nil?
329
+ # Client's synthetic death notification: the transport is gone, no
330
+ # further events can arrive — end the stream now rather than
331
+ # timing out.
332
+ raise TransportClosedError, msg["reason"] if msg["type"] == Client::TRANSPORT_CLOSED_TYPE
325
333
 
326
334
  event = Event.new(msg)
327
335
  yielder << event
@@ -15,20 +15,29 @@ module PiAgent
15
15
  class Subprocess
16
16
  DEFAULT_CHUNK_SIZE = 4096
17
17
  DEFAULT_CLOSE_TIMEOUT = 5
18
+ # How long to let the stdout reader drain buffered output after the
19
+ # child exits before reporting the death (or closing) anyway — a
20
+ # descendant that inherited the pipe can hold it open indefinitely.
21
+ EXIT_DRAIN_TIMEOUT = 1
18
22
 
19
23
  attr_reader :pid
20
24
 
21
25
  # `cwd` sets the child's working directory — pi's built-in tools
22
26
  # (bash/read/edit/...) operate relative to it. nil leaves the
23
27
  # child in this process's working directory.
24
- def initialize(command:, env: {}, cwd: nil, on_message: nil, on_stderr: nil)
28
+ #
29
+ # `on_close`, when given, is invoked exactly once with a short
30
+ # human-readable reason when the child exits on its own (see
31
+ # watch_exit). A caller-initiated #close is not reported.
32
+ def initialize(command:, env: {}, cwd: nil, on_message: nil, on_stderr: nil, on_close: nil)
25
33
  @command = Array(command)
26
34
  @env = env.transform_keys(&:to_s)
27
35
  @cwd = cwd
28
36
  @on_message = on_message
29
37
  @on_stderr = on_stderr
38
+ @on_close = on_close
30
39
  @write_mutex = Mutex.new
31
- @closed = false
40
+ @owner_closed = false
32
41
  end
33
42
 
34
43
  def start
@@ -39,13 +48,14 @@ module PiAgent
39
48
  @stderr.binmode
40
49
  @stdout_thread = Thread.new { read_loop(@stdout, :stdout) }
41
50
  @stderr_thread = Thread.new { read_loop(@stderr, :stderr) }
51
+ @exit_watch_thread = Thread.new { watch_exit }
42
52
  self
43
53
  end
44
54
 
45
55
  def write(obj)
46
56
  payload = "#{JSON.generate(obj)}\n"
47
57
  @write_mutex.synchronize do
48
- raise ProtocolError, "Transport closed" if @closed
58
+ raise ProtocolError, "Transport closed" if @owner_closed
49
59
 
50
60
  @stdin.write(payload)
51
61
  @stdin.flush
@@ -55,13 +65,18 @@ module PiAgent
55
65
  end
56
66
 
57
67
  def close(timeout: DEFAULT_CLOSE_TIMEOUT)
58
- return if mark_closed!
68
+ return if mark_owner_closed!
59
69
 
60
70
  safe_close(@stdin)
61
71
  wait_for_exit(timeout)
62
- [@stdout_thread, @stderr_thread].compact.each(&:join)
72
+ readers = [@stdout_thread, @stderr_thread].compact
73
+ # Bounded drain: a descendant that inherited a pipe can hold it
74
+ # open past the child's exit; force the readers out by closing
75
+ # the pipes after the window rather than hanging the close.
76
+ readers.each { |t| t.join(EXIT_DRAIN_TIMEOUT) }
63
77
  safe_close(@stdout)
64
78
  safe_close(@stderr)
79
+ (readers + [@exit_watch_thread]).compact.each(&:join)
65
80
  end
66
81
 
67
82
  def alive?
@@ -80,11 +95,13 @@ module PiAgent
80
95
  args
81
96
  end
82
97
 
83
- def mark_closed!
98
+ # Marks that the owner requested shutdown via #close (as opposed to
99
+ # the child dying on its own). Returns whether it was already set.
100
+ def mark_owner_closed!
84
101
  @write_mutex.synchronize do
85
- return true if @closed
102
+ return true if @owner_closed
86
103
 
87
- @closed = true
104
+ @owner_closed = true
88
105
  false
89
106
  end
90
107
  end
@@ -117,6 +134,21 @@ module PiAgent
117
134
  # Pipe closed; reader exits normally. (EOFError descends from IOError.)
118
135
  end
119
136
 
137
+ # Sole death notifier. Watches the child process itself rather than
138
+ # the stdout pipe, whose EOF a descendant that inherited the write
139
+ # end can defer indefinitely. Gives the stdout reader a bounded
140
+ # window to drain buffered output first, so responses that raced the
141
+ # exit are dispatched before the notification. Owner-initiated
142
+ # #close marks @owner_closed before teardown, so the expected exit
143
+ # stays silent.
144
+ def watch_exit
145
+ status = @wait_thr.value
146
+ @stdout_thread&.join(EXIT_DRAIN_TIMEOUT)
147
+ return if @on_close.nil? || owner_closed?
148
+
149
+ @on_close.call(close_reason(status))
150
+ end
151
+
120
152
  def dispatch_stdout(line)
121
153
  msg = JSON.parse(line)
122
154
  @on_message&.call(msg)
@@ -124,6 +156,18 @@ module PiAgent
124
156
  @on_stderr&.call("[pi-agent-rb] invalid JSON on stdout: #{e.message}: #{line.inspect}")
125
157
  end
126
158
 
159
+ def owner_closed?
160
+ @write_mutex.synchronize { @owner_closed }
161
+ end
162
+
163
+ def close_reason(status)
164
+ if status.signaled?
165
+ "process terminated by signal #{status.termsig}"
166
+ else
167
+ "process exited with status #{status.exitstatus}"
168
+ end
169
+ end
170
+
127
171
  def terminate_process
128
172
  Process.kill("TERM", @pid)
129
173
  rescue Errno::ESRCH, Errno::EPERM
@@ -6,10 +6,11 @@ module PiAgent
6
6
  # subprocess (Transport::Subprocess), or — via a caller-supplied
7
7
  # transport — a process inside a remote sandbox.
8
8
  #
9
- # Contract. A transport is constructed with two callables:
9
+ # Contract. A transport is constructed with callables:
10
10
  #
11
11
  # on_message: ->(Hash) # one parsed JSON message from pi's stdout
12
12
  # on_stderr: ->(String) # one line from pi's stderr
13
+ # on_close: ->(String) # death notification (optional; see below)
13
14
  #
14
15
  # and responds to:
15
16
  #
@@ -18,6 +19,23 @@ module PiAgent
18
19
  # #close(timeout:) -> shut pi down
19
20
  # #alive? -> Boolean
20
21
  #
22
+ # Death notification (on_close). A transport MUST invoke on_close
23
+ # exactly once when it reaches a terminal state on its own — child
24
+ # process exit, read-stream EOF, fatal stream error — with a short
25
+ # human-readable reason (e.g. "process terminated by signal 9").
26
+ # Dispatch any already-read stdout messages via on_message *before*
27
+ # calling on_close, so responses that raced the death are not lost. A
28
+ # shutdown initiated by the owner via #close is not a death and must
29
+ # not be reported. On notification, Client rejects in-flight requests
30
+ # and ends Session event streams promptly with TransportClosedError
31
+ # instead of letting them wait out their timeouts.
32
+ #
33
+ # on_close is optional for compatibility: Client inspects the transport
34
+ # factory's parameters and passes the keyword only when the factory
35
+ # accepts it (explicitly or via **kwargs). A factory with the older
36
+ # `(on_message:, on_stderr:)` shape keeps its previous behavior —
37
+ # request and event timeouts remain the only death backstop.
38
+ #
21
39
  # Implementations own framing (pi speaks strict-LF JSONL — see Framer)
22
40
  # and the thread-safety of #write. Client injects its own handlers via
23
41
  # a transport factory, so transports never need settable callbacks.
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module PiAgent
4
- VERSION = "0.2.2"
4
+ VERSION = "0.3.0"
5
5
 
6
6
  # Pinned upstream pi-coding-agent version this gem is verified against.
7
7
  # See: https://www.npmjs.com/package/@earendil-works/pi-coding-agent
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: pi-agent-rb
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.2
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - chagel
@@ -85,7 +85,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
85
85
  - !ruby/object:Gem::Version
86
86
  version: '0'
87
87
  requirements: []
88
- rubygems_version: 3.6.9
88
+ rubygems_version: 4.0.10
89
89
  specification_version: 4
90
90
  summary: Ruby client for the pi coding agent (drives `pi --mode rpc`)
91
91
  test_files: []