pi-agent-rb 0.2.1 → 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: eb219f216edc0f9a1077814bc561d50ced56bf59265665b5f62e508029421d64
4
- data.tar.gz: e17ce2b8d2766358b5504abd6fcff507633c3835d60db1256f15bdeb25e326ff
3
+ metadata.gz: ec081bcb7d1b2bac67301f37239e002e9b32d0eeb065d80a36b4d217896e1485
4
+ data.tar.gz: f19a72e0adb62fb42a49e3a21f2bcdffe3d46b3b546752c66e36427469c04c6e
5
5
  SHA512:
6
- metadata.gz: 334fbe076244a764f72daa7778e1ae19734c13a9ff414ced6691d5854c9704dc1462c08f0be66f90ac319bfaace117c0ef8c836f36a69d1483477ae3b79004ee
7
- data.tar.gz: d5d2e098f31f88615808708245776cf6c33b9c0d48ed70a87dbc0261dc53fe4e77d1e8b711e0a69a2b1600d0f55c7bbfd99239fdadb14d6addcfc29ff8db5890
6
+ metadata.gz: 1493e49939b44a45ff13fa7652b855ff48e25c611ad59bb6ac00c725bec549e76297156a455b20668bc5641a7fb0d8bcadd3eb535227e4e988c06c004a34dd92
7
+ data.tar.gz: 9cbd030aa9ae76199aa5730aab067bfd447a15416e9845d17fa3632f8baffd341586d0ca11b423eb4b66da6baa837c92511ec2ad7a6703a85c40e8d5996396d4
data/CHANGELOG.md CHANGED
@@ -7,6 +7,78 @@ 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
+
39
+ ## [0.2.2] - 2026-07-30
40
+
41
+ ### Added
42
+ - Added the optional `on_extension_ui_error` callback to `PiAgent.open`,
43
+ `PiAgent.session`, and `PiAgent::Client`. Applications can now observe an
44
+ Extension UI handler exception together with its request while the dialog
45
+ still fails closed as cancelled. Exceptions raised by the observer are also
46
+ isolated so they cannot prevent the cancellation response.
47
+
48
+ ### Changed
49
+ - Bumped pinned upstream `pi-coding-agent` version to `0.83.0` (from
50
+ `0.82.1`). This is a CLI/provider/extension batch; none of the changes
51
+ alter the JSONL RPC command/response shapes this gem drives, so no gem
52
+ API change is required.
53
+ - Adds `pi auth print-api-key` and `pi auth print-bearer-token` for
54
+ exporting configured credentials to external clients, with automatic
55
+ OAuth refresh and configurable minimum token validity. These are CLI
56
+ subcommands, outside the RPC surface this gem speaks.
57
+ - Adds headless OpenRouter sign-in (paste the redirect URL or
58
+ authorization code when the loopback callback is unavailable) and
59
+ Claude Opus 5 on GitHub Copilot with adaptive thinking and a 1M
60
+ context window. Both are provider/`/login` concerns.
61
+ - Exposes the session's resolved model scope as `ctx.scopedModels` to
62
+ extensions, adds a `"pending"` stop reason for partial streaming
63
+ messages, and surfaces raw provider stop reasons across Google,
64
+ Anthropic, Bedrock, Mistral, and OpenAI (unmapped terminal reasons now
65
+ surface as provider errors). These are SDK/extension and
66
+ provider-stream concerns; RPC event payloads still flow through
67
+ `PiAgent::Event` transparently.
68
+ - **Breaking for TypeScript extensions only:** upstream upgraded bundled
69
+ TypeBox aliases to 1.3.7, removing deprecated APIs (`Type.Base`,
70
+ `Type.Awaited`, `Type.Promise`, `Type.AsyncIterator`, `Type.Iterator`,
71
+ `Type.Options`, `Value.Mutate`) and fixing nullable-array tool-argument
72
+ validation. This does not affect this Ruby gem, which does not author
73
+ TypeBox schemas.
74
+ - The remainder are inherited fixes (tool-output expansion status line,
75
+ file-backed `SYSTEM.md`/`APPEND_SYSTEM.md` startup listing, worktree
76
+ context double-load, llama.cpp usage accounting, session replacement
77
+ during active responses, Git package install retries, `/model`
78
+ selector, direct RPC bash bypassing `user_bash` handlers, resource
79
+ metadata, and assorted provider fixes) that leave the RPC contract
80
+ unchanged.
81
+
10
82
  ## [0.2.1] - 2026-07-25
11
83
 
12
84
  ### Changed
data/README.md CHANGED
@@ -14,7 +14,7 @@ building interactive agent UIs (web, TUI) on top of pi.
14
14
 
15
15
  - Ruby 3.3+
16
16
  - `pi` 0.80.4+ on `PATH` (install via `npm i -g @earendil-works/pi-coding-agent`)
17
- - This gem is pinned against pi `0.82.1`; other versions may work but are not verified.
17
+ - This gem is pinned against pi `0.83.0`; other versions may work but are not verified.
18
18
 
19
19
  ## Installation
20
20
 
@@ -105,7 +105,7 @@ Pi emits `agent_end` after each low-level agent run, but may then retry,
105
105
  compact and retry, or process queued continuations. It emits
106
106
  `agent_settled` only when no automatic work remains, so high-level streams
107
107
  use that as their completion boundary. Upstream added the RPC event in pi
108
- 0.80.4; it is available in this gem's pinned pi 0.82.1.
108
+ 0.80.4; it is available in this gem's pinned pi 0.83.0.
109
109
 
110
110
  `events` is a lower-level, prompt-less drain of the same stream. Because it
111
111
  subscribes lazily when iteration begins, it only works when you subscribe
@@ -175,6 +175,32 @@ Returning `nil` from a dialog handler cancels it. With no handler,
175
175
  dialogs are auto-cancelled so the agent never hangs. Handlers run on
176
176
  their own thread and never block the event stream.
177
177
 
178
+ If a handler raises, the dialog is cancelled as a fail-safe. Use
179
+ `on_extension_ui_error` when the application needs to observe those failures:
180
+
181
+ ```ruby
182
+ on_ui_error = lambda do |error, request|
183
+ logger.error(
184
+ "Extension UI handler failed",
185
+ error: error.class.name,
186
+ request_id: request.id,
187
+ method: request.method
188
+ )
189
+ end
190
+
191
+ PiAgent.session(
192
+ extension_ui: handler,
193
+ on_extension_ui_error: on_ui_error
194
+ ) do |session|
195
+ session.prompt("Refactor the parser") { |e| ... }
196
+ end
197
+ ```
198
+
199
+ The observer runs on the same worker thread and cannot change the fail-safe
200
+ cancellation. If it raises, the dialog is still cancelled. Requests can contain
201
+ sensitive titles, messages, options, or prefilled text, so avoid logging
202
+ `request.raw` without application-specific filtering.
203
+
178
204
  ## Forking
179
205
 
180
206
  ```ruby
@@ -194,10 +220,60 @@ end
194
220
  a pi extension vetoes the operation — that is an expected outcome, not
195
221
  an error.
196
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
+
197
267
  ## Errors
198
268
 
199
269
  - A failed RPC command (`success: false`) raises `PiAgent::CommandError`,
200
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).
201
277
  - Agent-side errors arrive *in* the event stream, not as exceptions —
202
278
  inspect them with `Event#error?`, `#error_message`, and `#error_reason`
203
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)
@@ -52,8 +61,9 @@ module PiAgent
52
61
  end
53
62
 
54
63
  def initialize(bin: nil, args: DEFAULT_ARGS, env: {}, cwd: nil, approve: nil,
55
- extension_ui: nil, transport_factory: nil)
64
+ extension_ui: nil, on_extension_ui_error: nil, transport_factory: nil)
56
65
  @extension_ui_handler = extension_ui
66
+ @extension_ui_error_handler = on_extension_ui_error
57
67
  args = [*args, approve ? "--approve" : "--no-approve"] unless approve.nil?
58
68
  @transport_factory = transport_factory || build_subprocess_factory(bin, args, env, cwd)
59
69
  @pending = {}
@@ -63,14 +73,20 @@ module PiAgent
63
73
  @subscribers_mutex = Mutex.new
64
74
  @transport = nil
65
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
66
81
  end
67
82
 
68
83
  def start
69
- @transport = @transport_factory.call(
70
- on_message: method(:handle_message),
71
- on_stderr: method(:handle_stderr)
84
+ @transport = @transport_factory.call(**transport_callbacks)
85
+ @extension_ui = ExtensionUI.new(
86
+ writer: @transport,
87
+ handler: @extension_ui_handler,
88
+ on_error: @extension_ui_error_handler
72
89
  )
73
- @extension_ui = ExtensionUI.new(writer: @transport, handler: @extension_ui_handler)
74
90
  @transport.start
75
91
  self
76
92
  end
@@ -78,21 +94,44 @@ module PiAgent
78
94
  def request(type, params = {})
79
95
  id = next_id
80
96
  future = Future.new
81
- @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
82
102
  payload = { id: id, type: type }.merge(params)
83
- @transport.write(payload)
103
+ begin
104
+ @transport.write(payload)
105
+ rescue StandardError => e
106
+ raise abandon_request(id, e)
107
+ end
84
108
  future
85
109
  end
86
110
 
87
111
  def notify(type, params = {})
112
+ @pending_mutex.synchronize do
113
+ raise TransportClosedError, @close_reason if @close_reason
114
+ end
88
115
  payload = { type: type }.merge(params)
89
- @transport.write(payload)
116
+ begin
117
+ @transport.write(payload)
118
+ rescue StandardError => e
119
+ raise close_error_or(e)
120
+ end
90
121
  end
91
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.)
92
127
  def subscribe(&block)
93
128
  raise ArgumentError, "subscribe requires a block" unless block
94
129
 
95
- @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
96
135
  block
97
136
  end
98
137
 
@@ -101,6 +140,10 @@ module PiAgent
101
140
  end
102
141
 
103
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 }
104
147
  # Drain extension UI handler threads while the transport is still
105
148
  # open so their responses can still be written.
106
149
  @extension_ui&.shutdown
@@ -120,14 +163,42 @@ module PiAgent
120
163
  def build_subprocess_factory(bin, args, env, cwd)
121
164
  @bin = self.class.resolve_bin(bin)
122
165
  command = [@bin, *Array(args)]
123
- lambda do |on_message:, on_stderr:|
166
+ lambda do |on_message:, on_stderr:, on_close:|
124
167
  Transport::Subprocess.new(
125
168
  command: command, env: env, cwd: cwd,
126
- on_message: on_message, on_stderr: on_stderr
169
+ on_message: on_message, on_stderr: on_stderr, on_close: on_close
127
170
  )
128
171
  end
129
172
  end
130
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
+
131
202
  def next_id
132
203
  @pending_mutex.synchronize do
133
204
  @next_id += 1
@@ -158,21 +229,76 @@ module PiAgent
158
229
  end
159
230
 
160
231
  def notify_subscribers(msg)
161
- callbacks = @subscribers_mutex.synchronize { @subscribers.dup }
162
- 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
163
244
  end
164
245
 
165
246
  def handle_stderr(line)
166
247
  # no-op by default; future versions may wire a logger
167
248
  end
168
249
 
169
- 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)
170
258
  pending = @pending_mutex.synchronize do
171
- snapshot = @pending.values
172
- @pending.clear
173
- 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
174
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 }
175
275
  pending.each { |f| f.reject(error) }
176
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
177
303
  end
178
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,7 @@ module PiAgent
19
19
  # - fire-and-forget methods : return value ignored
20
20
  #
21
21
  # With no handler, dialogs are auto-cancelled so the agent never hangs.
22
+ # Handler failures also cancel the dialog; pass `on_error` to observe them.
22
23
  class ExtensionUI
23
24
  DIALOG_METHODS = %i[select confirm input editor].freeze
24
25
 
@@ -50,9 +51,10 @@ module PiAgent
50
51
  end
51
52
  end
52
53
 
53
- def initialize(writer:, handler: nil)
54
+ def initialize(writer:, handler: nil, on_error: nil)
54
55
  @writer = writer
55
56
  @handler = handler
57
+ @on_error = on_error
56
58
  @threads = []
57
59
  @mutex = Mutex.new
58
60
  end
@@ -75,7 +77,21 @@ module PiAgent
75
77
  private
76
78
 
77
79
  def handle(request)
78
- result = invoke_handler(request)
80
+ result, error = invoke_handler(request)
81
+ write_response(request, result)
82
+ notify_error(error, request) if error
83
+ end
84
+
85
+ def invoke_handler(request)
86
+ return [nil, nil] if @handler.nil?
87
+
88
+ [@handler.call(request), nil]
89
+ rescue StandardError => e
90
+ # A raising handler cancels the dialog rather than hanging the agent.
91
+ [nil, e]
92
+ end
93
+
94
+ def write_response(request, result)
79
95
  return unless request.dialog?
80
96
 
81
97
  @writer.write(response_for(request, result))
@@ -83,12 +99,10 @@ module PiAgent
83
99
  # Transport closed during shutdown; the response is moot.
84
100
  end
85
101
 
86
- def invoke_handler(request)
87
- return nil if @handler.nil?
88
-
89
- @handler.call(request)
102
+ def notify_error(error, request)
103
+ @on_error&.call(error, request)
90
104
  rescue StandardError
91
- # A raising handler cancels the dialog rather than hanging the agent.
105
+ # Error observers cannot prevent the fail-closed response.
92
106
  nil
93
107
  end
94
108
 
@@ -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,9 +1,9 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module PiAgent
4
- VERSION = "0.2.1"
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
8
- SUPPORTED_PI_VERSION = "0.82.1"
8
+ SUPPORTED_PI_VERSION = "0.83.0"
9
9
  end
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.1
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - chagel