acp-sdk 0.5.0 → 0.7.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: 77ac970489b956c7216d4c94a8d5cc6e7bf3fe5bfd10110cb8558c119f58f69d
4
- data.tar.gz: 29ec919463eb48d06811aa8b6c736163fd7872348ce99f3f8cdc10c5670bf8c4
3
+ metadata.gz: 978b245a94b3cd60d3858ee42c3deaf9b687c8a6a1701ce54c684a5e434b2bce
4
+ data.tar.gz: b83adfe811bc7873455f9f81993061e7b2ea66d0a75b78645807fc44e08cd58b
5
5
  SHA512:
6
- metadata.gz: acf89beeb444ff33ac273aececd55d645792fe591f346cbdce0a099b6b6bff4eedb4025cceadd3a46ec62253c85b111c0149466960707b3feaa92623dab600a8
7
- data.tar.gz: 92fba44ccd9334876fa8cb689728a7a19cc3d473bf1e7ee27fe215d72e48952e04bc9c0428609e31e883bdef6986e1ec7b4c9383d8a37271646593b33ff069bd
6
+ metadata.gz: 2907a71594882b7afd3ed8fe89b2e9520f139dc1c235a4e43bf836a2f127ae6264ce948d86885577daa6cd320078c6da831f4b8df89225201287276e17710c4e
7
+ data.tar.gz: 9191c0362932d37aaeb55666fb0b892505e448949f13e4173d0d9a0196ccebd16e56324f42db33f6c0d47fbec658bce6aac6e1bec4e4e9d4bdd4d6a1237ffc11
data/CHANGELOG.md CHANGED
@@ -1,5 +1,26 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.7.0](https://github.com/bottrall/acp-sdk/compare/v0.6.0...v0.7.0) (2026-10-05)
4
+
5
+
6
+ ### Features
7
+
8
+ * terminal handlers on ACP::ClientConnection ([#77](https://github.com/bottrall/acp-sdk/issues/77)) ([8a42829](https://github.com/bottrall/acp-sdk/commit/8a428295d5cd7c92b56ec1b3ed1c900b485bbf01)), closes [#40](https://github.com/bottrall/acp-sdk/issues/40)
9
+
10
+
11
+ ### Bug Fixes
12
+
13
+ * attach data to method-not-found and invalid-request replies ([#75](https://github.com/bottrall/acp-sdk/issues/75)) ([9bc201c](https://github.com/bottrall/acp-sdk/commit/9bc201c53d86f506bedb5b49183b865cf9b8f4fe)), closes [#29](https://github.com/bottrall/acp-sdk/issues/29)
14
+ * don't send handler exception messages to the peer ([#74](https://github.com/bottrall/acp-sdk/issues/74)) ([af66b48](https://github.com/bottrall/acp-sdk/commit/af66b48cbab4a99244c28a9c6f6a12c9ea23efc7)), closes [#28](https://github.com/bottrall/acp-sdk/issues/28)
15
+
16
+ ## [0.6.0](https://github.com/bottrall/acp-sdk/compare/v0.5.0...v0.6.0) (2026-10-04)
17
+
18
+
19
+ ### Features
20
+
21
+ * answer pending permission requests with cancelled after session_cancel ([#70](https://github.com/bottrall/acp-sdk/issues/70)) ([06a218b](https://github.com/bottrall/acp-sdk/commit/06a218b6262ff409e301d08a43330af5b60837e0))
22
+ * extension methods on both connections ([#72](https://github.com/bottrall/acp-sdk/issues/72)) ([d1faa1a](https://github.com/bottrall/acp-sdk/commit/d1faa1a738019e73a4dbad5b59f480795f8dd4bf))
23
+
3
24
  ## [0.5.0](https://github.com/bottrall/acp-sdk/compare/v0.4.0...v0.5.0) (2026-10-04)
4
25
 
5
26
 
data/README.md CHANGED
@@ -50,6 +50,7 @@ The agent's contract:
50
50
 
51
51
  - It defines `new_session`, `prompt` and `cancel`. `load_session`, `list_sessions`, `resume_session`, `close_session` and `delete_session` are required only when `capabilities` advertises `load_session` or `session_capabilities.list`, `.resume`, `.close` or `.delete`, and `authenticate` only when `auth_methods` is non-empty, and `logout` only when `capabilities` advertises `auth.logout`; `start` raises `ArgumentError` when one is advertised but missing, and a method that is not advertised is answered with `-32601`. `change_session_mode` and `change_session_config_option` are optional, since modes and config options are offered per session rather than in `initialize`; `session/set_mode` and `session/set_config_option` are answered with `-32601` when the agent does not define them. `session_created`, if defined, runs right after the `session/new` reply is sent.
52
52
  - Each request method returns its response type or an `ACP::RequestError`, which is sent as the error reply. Params that do not match the schema are answered with `-32602` before the agent sees them.
53
+ - A handler that raises is answered with `-32603` and the message `Internal error`. Nothing from the exception reaches the peer — its class and message are logged to the transport's logger instead, since messages can interpolate paths, queries or credentials. Return an `ACP::RequestError` from the handler when the peer should see a real error message.
53
54
  - `cancel` runs on the transport's reader thread, so it must return quickly: set a flag and let the prompt notice it.
54
55
  - After a cancel, the agent must itself end the turn with `stopReason: cancelled`. `ACP::AgentConnection` does not enforce it.
55
56
  - `client.capabilities` is `nil` until the client sends `initialize`.
@@ -57,11 +58,13 @@ The agent's contract:
57
58
  - `client.create_terminal`, `client.terminal_output`, `client.wait_for_terminal_exit`, `client.kill_terminal` and `client.release_terminal` take the matching `ACP::Types` request (`CreateTerminalRequest`, `TerminalOutputRequest`, `WaitForTerminalExitRequest`, `KillTerminalRequest`, `ReleaseTerminalRequest`) and return its response type or an `ACP::RequestError`. Unless `client.capabilities` advertises `terminal`, they return `-32601` without sending anything to the client. The agent must release every terminal it creates.
58
59
  - `client.create_elicitation` takes an `ACP::Types::CreateElicitationRequest` and returns an `ACP::Types::CreateElicitationResponse` or an `ACP::RequestError`. Unless `client.capabilities` advertises the `elicitation.form` or `elicitation.url` mode the request asks for, it returns `-32601` without sending anything to the client. A response action the SDK does not know comes through as the raw hash the client sent. `client.complete_elicitation` takes an `ACP::Types::CompleteElicitationNotification` and sends nothing, returning `-32601`, unless the client advertises `elicitation.url`, since only URL-mode elicitations are completed.
59
60
 
61
+ Extensions are `_`-prefixed methods outside the spec. `AgentConnection` takes `extension_requests` and `extension_notifications` hashes keyed by the raw wire name, and `client.ext_request` and `client.ext_notify` send them to the client; a handler name without the `_` prefix raises `ArgumentError`, and an extension request with no handler is answered with `-32601`.
62
+
60
63
  [`examples/echo_agent.rb`](https://github.com/bottrall/acp-sdk/blob/main/examples/echo_agent.rb) is a complete agent that streams updates, asks permission, handles cancellation, reads and writes files and runs commands through the client (`/read <path>`, `/write <path> <text>` and `/run <command> [args]`, each advertised as a slash command only when the client supports it), and supports `session/load`, `session/list`, `session/resume`, `session/close` and `session/delete`.
61
64
 
62
65
  ## Driving an agent
63
66
 
64
- `ACP::ClientConnection` is the other side of the same transport. Each method takes the request's generated `ACP::Types` object and returns its response type or the agent's `ACP::RequestError`. `connect` sends `initialize`, since Ruby reserves that name for the constructor, and `authenticate` and `logout` send their namesake methods. `session_set_mode` sends `session/set_mode`, and `session_set_config_option` sends `session/set_config_option` and returns the session's complete option list, which replaces any state held from an earlier reply.
67
+ `ACP::ClientConnection` is the other side of the same transport. Each method takes the request's generated `ACP::Types` object and returns its response type or the agent's `ACP::RequestError`. `connect` sends `initialize`, since Ruby reserves that name for the constructor, and `authenticate` and `logout` send their namesake methods. `session_set_mode` sends `session/set_mode`, and `session_set_config_option` sends `session/set_config_option` and returns the session's complete option list, which replaces any state held from an earlier reply. A handler that raises is answered with `-32603` and the message `Internal error`, and the exception is only logged to the transport's logger.
65
68
 
66
69
  `ACP::AgentProcess.spawn` starts a command with args, env and cwd, wires its stdio to a new `ACP::ClientConnection` and starts it. The block receives the connection and the process handle (`pid`, plus `stderr` when captured), and the child is terminated — after its stdin is closed, with TERM and then KILL if it will not exit — when the block exits, so a raised block cannot leak the process. Without a block, `spawn` returns the process and calling `terminate` is yours. `stderr` is `:inherit` by default, so the child writes to your stderr; pass `:capture` to collect it on the process instead.
67
70
 
@@ -103,6 +106,9 @@ connection = ACP::ClientConnection.new(
103
106
  - `session_prompt` and `session_load` yield the session's updates on the calling thread as they arrive and return once the agent replies. Updates outside those calls, such as the available commands after `session/new`, go to `updates`, which runs on the reader thread and must return quickly. `session_resume` does not take a block: the agent must not replay history on resume, so unlike `session_load` there is nothing to stream. `session_close` and `session_delete` end the session, discarding it on the agent, and return once the agent replies.
104
107
  - `permission` answers `session/request_permission` with an `ACP::Types::RequestPermissionResponse` or an `ACP::RequestError`. It runs on its own thread, so it may block while the user decides, and `session_cancel` can be sent meanwhile.
105
108
  - `read_text_file` and `write_text_file` answer the agent's `fs/read_text_file` and `fs/write_text_file` with an `ACP::Types::ReadTextFileResponse`, an `ACP::Types::WriteTextFileResponse` or an `ACP::RequestError`. They are routed by the `fs` capabilities `connect` sends: a method the client does not advertise is answered with `-32601` without reaching the handler, and `connect` raises `ArgumentError` — before sending `initialize` — when the request advertises a capability that has no handler.
109
+ - `create_terminal`, `terminal_output`, `wait_for_terminal_exit`, `kill_terminal` and `release_terminal` answer the agent's `terminal/create`, `terminal/output`, `terminal/wait_for_exit`, `terminal/kill` and `terminal/release` with the matching `ACP::Types` response or an `ACP::RequestError`. They are routed by the `terminal` capability `connect` sends, the same way as `fs`: a method the client does not advertise is answered with `-32601` without reaching the handler, and `connect` raises `ArgumentError` — before sending `initialize` — when the request advertises `terminal` with any of the five missing. The transport serves each request on its own thread, so `wait_for_terminal_exit` may block without holding up the others.
110
+
111
+ Extensions work the same way in both directions: `extension_requests` and `extension_notifications` are hashes keyed by the raw `_`-prefixed wire name, `ext_request` sends one to the agent and returns its result as-is, and `ext_notify` sends an extension notification. A handler name without the `_` prefix raises `ArgumentError`, and an extension request nothing answers gets `-32601`.
106
112
 
107
113
  ## Contributing
108
114
 
@@ -105,6 +105,26 @@ class ACP::AgentConnection::Client
105
105
  @peer.notify('elicitation/complete', notification.to_h)
106
106
  end
107
107
 
108
+ # Sends an extension request, keyed by its raw `_`-prefixed wire name, and
109
+ # returns the client's reply as-is: extension methods have no schema to
110
+ # parse the result into.
111
+ #
112
+ # @rbs method: String
113
+ # @rbs params: untyped
114
+ # @rbs return: (Hash[String, untyped] | ACP::RequestError)
115
+ def ext_request(method, params = nil)
116
+ ACP::Extensions.validate_name(method)
117
+ @peer.request(method, params)
118
+ end
119
+
120
+ # @rbs method: String
121
+ # @rbs params: untyped
122
+ # @rbs return: void
123
+ def ext_notify(method, params = nil)
124
+ ACP::Extensions.validate_name(method)
125
+ @peer.notify(method, params)
126
+ end
127
+
108
128
  private
109
129
 
110
130
  # @rbs rpc_method: String
@@ -6,6 +6,8 @@ class ACP::AgentConnection
6
6
  # @rbs @agent_info: ACP::Types::Implementation?
7
7
  # @rbs @auth_methods: Array[ACP::Types::AuthMethod::t]
8
8
  # @rbs @factory: ^(ACP::AgentConnection::Client) -> ACP::AgentConnection::_Agent
9
+ # @rbs @extension_requests: Hash[String, ^(untyped) -> untyped]
10
+ # @rbs @extension_notifications: Hash[String, ^(untyped) -> void]
9
11
  # @rbs @logger: ACP::Transport::_Logger
10
12
 
11
13
  PROTOCOL_VERSION = 1 #: Integer
@@ -30,6 +32,8 @@ class ACP::AgentConnection
30
32
  # @rbs capabilities: ACP::Types::AgentCapabilities
31
33
  # @rbs agent_info: ACP::Types::Implementation?
32
34
  # @rbs auth_methods: Array[ACP::Types::AuthMethod::t]
35
+ # @rbs extension_requests: Hash[String, ^(untyped) -> untyped]
36
+ # @rbs extension_notifications: Hash[String, ^(untyped) -> void]
33
37
  # @rbs logger: ACP::Transport::_Logger
34
38
  # @rbs &factory: (ACP::AgentConnection::Client) -> ACP::AgentConnection::_Agent
35
39
  # @rbs return: void
@@ -38,6 +42,8 @@ class ACP::AgentConnection
38
42
  capabilities:,
39
43
  agent_info: nil,
40
44
  auth_methods: [],
45
+ extension_requests: {},
46
+ extension_notifications: {},
41
47
  logger: ACP::Transport::StderrLogger.new,
42
48
  &factory
43
49
  )
@@ -46,6 +52,10 @@ class ACP::AgentConnection
46
52
  @capabilities = capabilities
47
53
  @agent_info = agent_info
48
54
  @auth_methods = auth_methods
55
+ @extension_requests = extension_requests
56
+ @extension_notifications = extension_notifications
57
+ extension_requests.each_key { |name| ACP::Extensions.validate_name(name) }
58
+ extension_notifications.each_key { |name| ACP::Extensions.validate_name(name) }
49
59
  @factory = factory
50
60
  end
51
61
 
@@ -61,7 +71,10 @@ class ACP::AgentConnection
61
71
  raise ArgumentError, "initialize advertises methods the agent lacks: #{missing.join(', ')}" unless missing.empty?
62
72
 
63
73
  unrouted = unadvertised.map(&:rpc_method) + PER_SESSION.reject { |_, name| agent.respond_to?(name) }.keys
64
- @transport.start(requests: requests(agent, client).except(*unrouted), notifications: notifications(agent))
74
+ @transport.start(
75
+ requests: requests(agent, client).except(*unrouted).merge(@extension_requests),
76
+ notifications: notifications(agent).merge(@extension_notifications)
77
+ )
65
78
  end
66
79
 
67
80
  private
@@ -5,32 +5,79 @@ class ACP::ClientConnection
5
5
  # @rbs @permission: ACP::ClientConnection::_PermissionHandler
6
6
  # @rbs @read_text_file: (^(ACP::Types::ReadTextFileRequest) -> (ACP::Types::ReadTextFileResponse | ACP::RequestError))?
7
7
  # @rbs @write_text_file: (^(ACP::Types::WriteTextFileRequest) -> (ACP::Types::WriteTextFileResponse | ACP::RequestError))?
8
+ # @rbs @create_terminal: (^(ACP::Types::CreateTerminalRequest) -> (ACP::Types::CreateTerminalResponse | ACP::RequestError))?
9
+ # @rbs @terminal_output: (^(ACP::Types::TerminalOutputRequest) -> (ACP::Types::TerminalOutputResponse | ACP::RequestError))?
10
+ # @rbs @wait_for_terminal_exit: (^(ACP::Types::WaitForTerminalExitRequest) ->
11
+ # (ACP::Types::WaitForTerminalExitResponse | ACP::RequestError))?
12
+ # @rbs @kill_terminal: (^(ACP::Types::KillTerminalRequest) -> (ACP::Types::KillTerminalResponse | ACP::RequestError))?
13
+ # @rbs @release_terminal: (^(ACP::Types::ReleaseTerminalRequest) -> (ACP::Types::ReleaseTerminalResponse | ACP::RequestError))?
8
14
  # @rbs @updates: ACP::ClientConnection::_UpdateHandler
9
15
  # @rbs @fs_capabilities: ACP::Types::FileSystemCapabilities?
16
+ # @rbs @terminal: bool?
17
+ # @rbs @extension_requests: Hash[String, ^(untyped) -> untyped]
18
+ # @rbs @extension_notifications: Hash[String, ^(untyped) -> void]
10
19
  # @rbs @lock: Thread::Mutex
11
20
  # @rbs @streams: Hash[String, Thread::Queue]
21
+ # @rbs @pending_permissions: Hash[String, Array[Thread::Queue]]
12
22
  # @rbs @logger: ACP::Transport::_Logger
13
23
 
14
24
  PROTOCOL_VERSION = ACP::AgentConnection::PROTOCOL_VERSION #: Integer
15
25
 
16
26
  IGNORE = ->(_notification) {} #: ^(ACP::Types::SessionNotification) -> void
17
27
 
28
+ CANCELLED = ACP::Types::RequestPermissionResponse.new(
29
+ outcome: ACP::Types::RequestPermissionOutcome::Cancelled.new
30
+ ) #: ACP::Types::RequestPermissionResponse
31
+
18
32
  # @rbs transport: ACP::AgentConnection::_Transport
19
33
  # @rbs permission: ACP::ClientConnection::_PermissionHandler
20
34
  # @rbs read_text_file: (^(ACP::Types::ReadTextFileRequest) -> (ACP::Types::ReadTextFileResponse | ACP::RequestError))?
21
35
  # @rbs write_text_file: (^(ACP::Types::WriteTextFileRequest) -> (ACP::Types::WriteTextFileResponse | ACP::RequestError))?
36
+ # @rbs create_terminal: (^(ACP::Types::CreateTerminalRequest) -> (ACP::Types::CreateTerminalResponse | ACP::RequestError))?
37
+ # @rbs terminal_output: (^(ACP::Types::TerminalOutputRequest) -> (ACP::Types::TerminalOutputResponse | ACP::RequestError))?
38
+ # @rbs wait_for_terminal_exit: (^(ACP::Types::WaitForTerminalExitRequest) ->
39
+ # (ACP::Types::WaitForTerminalExitResponse | ACP::RequestError))?
40
+ # @rbs kill_terminal: (^(ACP::Types::KillTerminalRequest) -> (ACP::Types::KillTerminalResponse | ACP::RequestError))?
41
+ # @rbs release_terminal: (^(ACP::Types::ReleaseTerminalRequest) -> (ACP::Types::ReleaseTerminalResponse | ACP::RequestError))?
22
42
  # @rbs updates: ACP::ClientConnection::_UpdateHandler
43
+ # @rbs extension_requests: Hash[String, ^(untyped) -> untyped]
44
+ # @rbs extension_notifications: Hash[String, ^(untyped) -> void]
23
45
  # @rbs logger: ACP::Transport::_Logger
24
46
  # @rbs return: void
25
- def initialize(transport:, permission:, read_text_file: nil, write_text_file: nil, updates: IGNORE, logger: ACP::Transport::StderrLogger.new)
47
+ def initialize(
48
+ transport:,
49
+ permission:,
50
+ read_text_file: nil,
51
+ write_text_file: nil,
52
+ create_terminal: nil,
53
+ terminal_output: nil,
54
+ wait_for_terminal_exit: nil,
55
+ kill_terminal: nil,
56
+ release_terminal: nil,
57
+ updates: IGNORE,
58
+ extension_requests: {},
59
+ extension_notifications: {},
60
+ logger: ACP::Transport::StderrLogger.new
61
+ )
26
62
  @transport = transport
27
63
  @permission = permission
28
64
  @read_text_file = read_text_file
29
65
  @write_text_file = write_text_file
66
+ @create_terminal = create_terminal
67
+ @terminal_output = terminal_output
68
+ @wait_for_terminal_exit = wait_for_terminal_exit
69
+ @kill_terminal = kill_terminal
70
+ @release_terminal = release_terminal
30
71
  @updates = updates
31
72
  @fs_capabilities = nil
73
+ @terminal = nil
74
+ @extension_requests = extension_requests
75
+ @extension_notifications = extension_notifications
76
+ extension_requests.each_key { |name| ACP::Extensions.validate_name(name) }
77
+ extension_notifications.each_key { |name| ACP::Extensions.validate_name(name) }
32
78
  @lock = Mutex.new
33
79
  @streams = {}
80
+ @pending_permissions = {}
34
81
  @logger = logger
35
82
  end
36
83
 
@@ -40,9 +87,14 @@ class ACP::ClientConnection
40
87
  requests: {
41
88
  'session/request_permission' => method(:request_permission),
42
89
  'fs/read_text_file' => method(:read_text_file),
43
- 'fs/write_text_file' => method(:write_text_file)
44
- },
45
- notifications: { 'session/update' => method(:dispatch) }
90
+ 'fs/write_text_file' => method(:write_text_file),
91
+ 'terminal/create' => method(:create_terminal),
92
+ 'terminal/output' => method(:terminal_output),
93
+ 'terminal/wait_for_exit' => method(:wait_for_terminal_exit),
94
+ 'terminal/kill' => method(:kill_terminal),
95
+ 'terminal/release' => method(:release_terminal)
96
+ }.merge(@extension_requests),
97
+ notifications: { 'session/update' => method(:dispatch) }.merge(@extension_notifications)
46
98
  )
47
99
  end
48
100
 
@@ -58,7 +110,14 @@ class ACP::ClientConnection
58
110
  raise ArgumentError, "initialize advertises fs methods no handler serves: #{unserved.join(', ')}"
59
111
  end
60
112
 
113
+ terminal = request.client_capabilities&.terminal
114
+ unserved = unserved_terminal_methods(terminal)
115
+ unless unserved.empty?
116
+ raise ArgumentError, "initialize advertises terminal methods no handler serves: #{unserved.join(', ')}"
117
+ end
118
+
61
119
  @fs_capabilities = capabilities
120
+ @terminal = terminal
62
121
  parse(ACP::Types::InitializeResponse, @transport.request('initialize', request.to_h)).then do |response|
63
122
  next response if response.is_a?(ACP::RequestError)
64
123
 
@@ -118,10 +177,17 @@ class ACP::ClientConnection
118
177
  parse(ACP::Types::DeleteSessionResponse, @transport.request('session/delete', request.to_h))
119
178
  end
120
179
 
180
+ # The spec requires a pending session/request_permission to be answered with
181
+ # the cancelled outcome once the turn is cancelled, so every queue registered
182
+ # for the session gets one before this returns. The notification goes first
183
+ # so the wire order is cancel, then the cancelled replies.
184
+ #
121
185
  # @rbs notification: ACP::Types::CancelNotification
122
186
  # @rbs return: void
123
187
  def session_cancel(notification)
124
188
  @transport.notify('session/cancel', notification.to_h)
189
+ pending = @lock.synchronize { @pending_permissions.delete(notification.session_id) }
190
+ pending&.each { |replies| replies << [:value, CANCELLED] }
125
191
  end
126
192
 
127
193
  # @rbs request: ACP::Types::SetSessionModeRequest
@@ -151,6 +217,26 @@ class ACP::ClientConnection
151
217
  parse(ACP::Types::LogoutResponse, @transport.request('logout', request.to_h))
152
218
  end
153
219
 
220
+ # Sends an extension request, keyed by its raw `_`-prefixed wire name, and
221
+ # returns the agent's reply as-is: extension methods have no schema to
222
+ # parse the result into.
223
+ #
224
+ # @rbs method: String
225
+ # @rbs params: untyped
226
+ # @rbs return: (Hash[String, untyped] | ACP::RequestError)
227
+ def ext_request(method, params = nil)
228
+ ACP::Extensions.validate_name(method)
229
+ @transport.request(method, params)
230
+ end
231
+
232
+ # @rbs method: String
233
+ # @rbs params: untyped
234
+ # @rbs return: void
235
+ def ext_notify(method, params = nil)
236
+ ACP::Extensions.validate_name(method)
237
+ @transport.notify(method, params)
238
+ end
239
+
154
240
  private
155
241
 
156
242
  # @rbs type: ACP::AgentConnection::_Parser
@@ -216,6 +302,10 @@ class ACP::ClientConnection
216
302
  @updates.call(notification) unless queue
217
303
  end
218
304
 
305
+ # The handler runs on its own thread so a cancelled request is answered
306
+ # without waiting for it, and whatever it returns after that is pushed to a
307
+ # queue nobody reads.
308
+ #
219
309
  # @rbs params: untyped
220
310
  # @rbs return: (ACP::Types::RequestPermissionResponse | ACP::RequestError)
221
311
  def request_permission(params)
@@ -223,7 +313,45 @@ class ACP::ClientConnection
223
313
  rescue KeyError, TypeError, NoMethodError
224
314
  ACP::RequestError.invalid_params
225
315
  else
226
- @permission.call(request)
316
+ replies = Thread::Queue.new
317
+ register_permission(request.session_id, replies)
318
+ Thread.new do
319
+ # A raised StandardError is not a returned ACP::RequestError, so the two
320
+ # need distinct shapes on the queue.
321
+ replies << begin
322
+ [:value, @permission.call(request)]
323
+ rescue StandardError => e
324
+ [:raised, e]
325
+ end
326
+ ensure
327
+ unregister_permission(request.session_id, replies)
328
+ end
329
+ settled = replies.pop
330
+ # Re-raised so the transport's invoke turns it into an error reply, as it
331
+ # would if the handler ran on the serve thread itself.
332
+ raise settled[1] if settled[0] == :raise
333
+
334
+ settled[1]
335
+ end
336
+
337
+ # @rbs session_id: String
338
+ # @rbs replies: Thread::Queue
339
+ # @rbs return: void
340
+ def register_permission(session_id, replies)
341
+ @lock.synchronize { (@pending_permissions[session_id] ||= []) << replies }
342
+ end
343
+
344
+ # @rbs session_id: String
345
+ # @rbs replies: Thread::Queue
346
+ # @rbs return: void
347
+ def unregister_permission(session_id, replies)
348
+ @lock.synchronize do
349
+ pending = @pending_permissions[session_id]
350
+ if pending
351
+ pending.delete(replies)
352
+ @pending_permissions.delete(session_id) if pending.empty?
353
+ end
354
+ end
227
355
  end
228
356
 
229
357
  # The routes are registered at start, before connect records the advertised
@@ -257,6 +385,82 @@ class ACP::ClientConnection
257
385
  handler.call(request)
258
386
  end
259
387
 
388
+ # The terminal routes are registered at start, before connect records the
389
+ # advertised capability, so each one answers -32601 until then.
390
+ #
391
+ # @rbs params: untyped
392
+ # @rbs return: (ACP::Types::CreateTerminalResponse | ACP::RequestError)
393
+ def create_terminal(params)
394
+ return ACP::RequestError.unadvertised('terminal') unless @terminal
395
+
396
+ request = ACP::Types::CreateTerminalRequest.from_h(params)
397
+ rescue KeyError, TypeError, NoMethodError
398
+ ACP::RequestError.invalid_params
399
+ else
400
+ # Safe: connect refuses an advertised capability no handler serves.
401
+ handler = @create_terminal #: ^(ACP::Types::CreateTerminalRequest) -> (ACP::Types::CreateTerminalResponse | ACP::RequestError)
402
+ handler.call(request)
403
+ end
404
+
405
+ # @rbs params: untyped
406
+ # @rbs return: (ACP::Types::TerminalOutputResponse | ACP::RequestError)
407
+ def terminal_output(params)
408
+ return ACP::RequestError.unadvertised('terminal') unless @terminal
409
+
410
+ request = ACP::Types::TerminalOutputRequest.from_h(params)
411
+ rescue KeyError, TypeError, NoMethodError
412
+ ACP::RequestError.invalid_params
413
+ else
414
+ # Safe: connect refuses an advertised capability no handler serves.
415
+ handler = @terminal_output #: ^(ACP::Types::TerminalOutputRequest) -> (ACP::Types::TerminalOutputResponse | ACP::RequestError)
416
+ handler.call(request)
417
+ end
418
+
419
+ # The handler may block for as long as the command runs, so the transport
420
+ # must serve each request on its own thread for this not to hold up others.
421
+ #
422
+ # @rbs params: untyped
423
+ # @rbs return: (ACP::Types::WaitForTerminalExitResponse | ACP::RequestError)
424
+ def wait_for_terminal_exit(params)
425
+ return ACP::RequestError.unadvertised('terminal') unless @terminal
426
+
427
+ request = ACP::Types::WaitForTerminalExitRequest.from_h(params)
428
+ rescue KeyError, TypeError, NoMethodError
429
+ ACP::RequestError.invalid_params
430
+ else
431
+ # Safe: connect refuses an advertised capability no handler serves.
432
+ handler = @wait_for_terminal_exit #: ^(ACP::Types::WaitForTerminalExitRequest) -> (ACP::Types::WaitForTerminalExitResponse | ACP::RequestError)
433
+ handler.call(request)
434
+ end
435
+
436
+ # @rbs params: untyped
437
+ # @rbs return: (ACP::Types::KillTerminalResponse | ACP::RequestError)
438
+ def kill_terminal(params)
439
+ return ACP::RequestError.unadvertised('terminal') unless @terminal
440
+
441
+ request = ACP::Types::KillTerminalRequest.from_h(params)
442
+ rescue KeyError, TypeError, NoMethodError
443
+ ACP::RequestError.invalid_params
444
+ else
445
+ # Safe: connect refuses an advertised capability no handler serves.
446
+ handler = @kill_terminal #: ^(ACP::Types::KillTerminalRequest) -> (ACP::Types::KillTerminalResponse | ACP::RequestError)
447
+ handler.call(request)
448
+ end
449
+
450
+ # @rbs params: untyped
451
+ # @rbs return: (ACP::Types::ReleaseTerminalResponse | ACP::RequestError)
452
+ def release_terminal(params)
453
+ return ACP::RequestError.unadvertised('terminal') unless @terminal
454
+
455
+ request = ACP::Types::ReleaseTerminalRequest.from_h(params)
456
+ rescue KeyError, TypeError, NoMethodError
457
+ ACP::RequestError.invalid_params
458
+ else
459
+ # Safe: connect refuses an advertised capability no handler serves.
460
+ handler = @release_terminal #: ^(ACP::Types::ReleaseTerminalRequest) -> (ACP::Types::ReleaseTerminalResponse | ACP::RequestError)
461
+ handler.call(request)
462
+ end
463
+
260
464
  # The fs capabilities initialize advertises that no injected handler serves.
261
465
  #
262
466
  # @rbs capabilities: ACP::Types::FileSystemCapabilities?
@@ -267,4 +471,19 @@ class ACP::ClientConnection
267
471
  'fs.writeTextFile' => capabilities&.write_text_file && !@write_text_file
268
472
  }.select { |_, unserved| unserved }.keys
269
473
  end
474
+
475
+ # The terminal capabilities initialize advertises that no injected handler
476
+ # serves.
477
+ #
478
+ # @rbs capabilities: bool?
479
+ # @rbs return: Array[String]
480
+ def unserved_terminal_methods(capabilities)
481
+ {
482
+ 'terminal.create' => capabilities && !@create_terminal,
483
+ 'terminal.output' => capabilities && !@terminal_output,
484
+ 'terminal.wait_for_exit' => capabilities && !@wait_for_terminal_exit,
485
+ 'terminal.kill' => capabilities && !@kill_terminal,
486
+ 'terminal.release' => capabilities && !@release_terminal
487
+ }.select { |_, unserved| unserved }.keys
488
+ end
270
489
  end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Shared by the connections' handler maps and send paths, so a plain method
4
+ # name cannot slip into the extension routes.
5
+ module ACP::Extensions
6
+ extend self
7
+
8
+ # @rbs name: String
9
+ # @rbs return: void
10
+ def validate_name(name)
11
+ return if name.start_with?('_')
12
+
13
+ raise ArgumentError, "extension method names must start with '_' (got #{name.inspect})"
14
+ end
15
+ end
@@ -48,9 +48,10 @@ class ACP::RequestError
48
48
  new(code: INVALID_REQUEST, message: 'Invalid request')
49
49
  end
50
50
 
51
+ # @rbs method: String?
51
52
  # @rbs return: ACP::RequestError
52
- def self.method_not_found
53
- new(code: METHOD_NOT_FOUND, message: 'Method not found')
53
+ def self.method_not_found(method = nil)
54
+ new(code: METHOD_NOT_FOUND, message: 'Method not found', data: method && { method: method })
54
55
  end
55
56
 
56
57
  # @rbs return: ACP::RequestError
@@ -92,7 +92,7 @@ class ACP::Transport::Stdio
92
92
  method = message['method']
93
93
  error = message['error']
94
94
  if method.is_a?(String) && message.key?('id')
95
- serve(requests[method], message['id'], message['params'])
95
+ serve(requests[method], message['id'], message['params'], method)
96
96
  elsif method.is_a?(String)
97
97
  # Run on the reader thread so notifications (session/update) keep their
98
98
  # order; a JSON-RPC notification has no reply to carry a handler error.
@@ -104,10 +104,21 @@ class ACP::Transport::Stdio
104
104
  code, text, data = error.values_at('code', 'message', 'data')
105
105
  settle(message['id'], ACP::RequestError.new(code:, message: text, data:))
106
106
  else
107
- reply(nil, ACP::RequestError.invalid_request)
107
+ reply(detectable_id(message), ACP::RequestError.invalid_request)
108
108
  end
109
109
  end
110
110
 
111
+ # JSON-RPC 2.0 says an error reply echoes the request id whenever it can be
112
+ # detected; ids outside the spec's String/Number/Null types are not, so
113
+ # those replies keep id null.
114
+ #
115
+ # @rbs message: Hash[String, untyped]
116
+ # @rbs return: untyped
117
+ def detectable_id(message)
118
+ id = message['id']
119
+ id.is_a?(String) || id.is_a?(Numeric) ? id : nil
120
+ end
121
+
111
122
  # @rbs line: String
112
123
  # @rbs return: Hash[String, untyped] | ACP::RequestError
113
124
  def parse(line)
@@ -122,10 +133,11 @@ class ACP::Transport::Stdio
122
133
  # @rbs handler: _Handler?
123
134
  # @rbs id: untyped
124
135
  # @rbs params: untyped
136
+ # @rbs method: String
125
137
  # @rbs return: void
126
- def serve(handler, id, params)
138
+ def serve(handler, id, params, method)
127
139
  Thread.new do
128
- outcome = handler ? invoke(handler, params) : ACP::RequestError.method_not_found
140
+ outcome = handler ? invoke(handler, params) : ACP::RequestError.method_not_found(method)
129
141
  case outcome
130
142
  when ACP::Transport::Reply
131
143
  reply(id, outcome.result)
@@ -149,7 +161,9 @@ class ACP::Transport::Stdio
149
161
  end
150
162
 
151
163
  # Handlers are application code at the protocol boundary: an exception
152
- # becomes an error response instead of killing the thread serving it.
164
+ # becomes an error response instead of killing the thread serving it. The
165
+ # exception is only logged, not sent: its message can carry paths, SQL or
166
+ # anything else the handler interpolated, which must not reach the peer.
153
167
  #
154
168
  # @rbs handler: _Handler
155
169
  # @rbs params: untyped
@@ -157,7 +171,8 @@ class ACP::Transport::Stdio
157
171
  def invoke(handler, params)
158
172
  handler.call(params)
159
173
  rescue StandardError => e
160
- ACP::RequestError.new(code: ACP::RequestError::INTERNAL_ERROR, message: e.message)
174
+ @logger.error("request handler raised #{e.class}: #{e.message}")
175
+ ACP::RequestError.internal_error
161
176
  end
162
177
 
163
178
  # The success arm is whatever the handler returned, which the transport only
data/lib/acp/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ACP
4
- VERSION = '0.5.0'
4
+ VERSION = '0.7.0'
5
5
  end
@@ -63,6 +63,20 @@ class ACP::AgentConnection::Client
63
63
  # @rbs return: void
64
64
  def complete_elicitation: (ACP::Types::CompleteElicitationNotification notification) -> void
65
65
 
66
+ # Sends an extension request, keyed by its raw `_`-prefixed wire name, and
67
+ # returns the client's reply as-is: extension methods have no schema to
68
+ # parse the result into.
69
+ #
70
+ # @rbs method: String
71
+ # @rbs params: untyped
72
+ # @rbs return: (Hash[String, untyped] | ACP::RequestError)
73
+ def ext_request: (String method, ?untyped params) -> (Hash[String, untyped] | ACP::RequestError)
74
+
75
+ # @rbs method: String
76
+ # @rbs params: untyped
77
+ # @rbs return: void
78
+ def ext_notify: (String method, ?untyped params) -> void
79
+
66
80
  private
67
81
 
68
82
  # @rbs rpc_method: String
@@ -3,6 +3,10 @@
3
3
  class ACP::AgentConnection
4
4
  @logger: ACP::Transport::_Logger
5
5
 
6
+ @extension_notifications: Hash[String, ^(untyped) -> void]
7
+
8
+ @extension_requests: Hash[String, ^(untyped) -> untyped]
9
+
6
10
  @factory: ^(ACP::AgentConnection::Client) -> ACP::AgentConnection::_Agent
7
11
 
8
12
  @auth_methods: Array[ACP::Types::AuthMethod::t]
@@ -26,10 +30,12 @@ class ACP::AgentConnection
26
30
  # @rbs capabilities: ACP::Types::AgentCapabilities
27
31
  # @rbs agent_info: ACP::Types::Implementation?
28
32
  # @rbs auth_methods: Array[ACP::Types::AuthMethod::t]
33
+ # @rbs extension_requests: Hash[String, ^(untyped) -> untyped]
34
+ # @rbs extension_notifications: Hash[String, ^(untyped) -> void]
29
35
  # @rbs logger: ACP::Transport::_Logger
30
36
  # @rbs &factory: (ACP::AgentConnection::Client) -> ACP::AgentConnection::_Agent
31
37
  # @rbs return: void
32
- def initialize: (transport: ACP::AgentConnection::_Transport, capabilities: ACP::Types::AgentCapabilities, ?agent_info: ACP::Types::Implementation?, ?auth_methods: Array[ACP::Types::AuthMethod::t], ?logger: ACP::Transport::_Logger) { (ACP::AgentConnection::Client) -> ACP::AgentConnection::_Agent } -> void
38
+ def initialize: (transport: ACP::AgentConnection::_Transport, capabilities: ACP::Types::AgentCapabilities, ?agent_info: ACP::Types::Implementation?, ?auth_methods: Array[ACP::Types::AuthMethod::t], ?extension_requests: Hash[String, ^(untyped) -> untyped], ?extension_notifications: Hash[String, ^(untyped) -> void], ?logger: ACP::Transport::_Logger) { (ACP::AgentConnection::Client) -> ACP::AgentConnection::_Agent } -> void
33
39
 
34
40
  # @rbs return: Thread
35
41
  def start: () -> Thread
@@ -1,36 +1,64 @@
1
1
  # Generated from lib/acp/client_connection.rb with RBS::Inline
2
2
 
3
3
  class ACP::ClientConnection
4
- @logger: ACP::Transport::_Logger
4
+ @transport: ACP::AgentConnection::_Transport
5
5
 
6
- @streams: Hash[String, Thread::Queue]
6
+ @permission: ACP::ClientConnection::_PermissionHandler
7
7
 
8
- @lock: Thread::Mutex
8
+ @read_text_file: (^(ACP::Types::ReadTextFileRequest) -> (ACP::Types::ReadTextFileResponse | ACP::RequestError))?
9
9
 
10
- @fs_capabilities: ACP::Types::FileSystemCapabilities?
10
+ @write_text_file: (^(ACP::Types::WriteTextFileRequest) -> (ACP::Types::WriteTextFileResponse | ACP::RequestError))?
11
+
12
+ @create_terminal: (^(ACP::Types::CreateTerminalRequest) -> (ACP::Types::CreateTerminalResponse | ACP::RequestError))?
13
+
14
+ @terminal_output: (^(ACP::Types::TerminalOutputRequest) -> (ACP::Types::TerminalOutputResponse | ACP::RequestError))?
15
+
16
+ @wait_for_terminal_exit: (^(ACP::Types::WaitForTerminalExitRequest) -> (ACP::Types::WaitForTerminalExitResponse | ACP::RequestError))?
17
+
18
+ @kill_terminal: (^(ACP::Types::KillTerminalRequest) -> (ACP::Types::KillTerminalResponse | ACP::RequestError))?
19
+
20
+ @release_terminal: (^(ACP::Types::ReleaseTerminalRequest) -> (ACP::Types::ReleaseTerminalResponse | ACP::RequestError))?
11
21
 
12
22
  @updates: ACP::ClientConnection::_UpdateHandler
13
23
 
14
- @write_text_file: (^(ACP::Types::WriteTextFileRequest) -> (ACP::Types::WriteTextFileResponse | ACP::RequestError))?
24
+ @fs_capabilities: ACP::Types::FileSystemCapabilities?
15
25
 
16
- @read_text_file: (^(ACP::Types::ReadTextFileRequest) -> (ACP::Types::ReadTextFileResponse | ACP::RequestError))?
26
+ @terminal: bool?
17
27
 
18
- @permission: ACP::ClientConnection::_PermissionHandler
28
+ @extension_requests: Hash[String, ^(untyped) -> untyped]
19
29
 
20
- @transport: ACP::AgentConnection::_Transport
30
+ @extension_notifications: Hash[String, ^(untyped) -> void]
31
+
32
+ @lock: Thread::Mutex
33
+
34
+ @streams: Hash[String, Thread::Queue]
35
+
36
+ @pending_permissions: Hash[String, Array[Thread::Queue]]
37
+
38
+ @logger: ACP::Transport::_Logger
21
39
 
22
40
  PROTOCOL_VERSION: Integer
23
41
 
24
42
  IGNORE: ^(ACP::Types::SessionNotification) -> void
25
43
 
44
+ CANCELLED: ACP::Types::RequestPermissionResponse
45
+
26
46
  # @rbs transport: ACP::AgentConnection::_Transport
27
47
  # @rbs permission: ACP::ClientConnection::_PermissionHandler
28
48
  # @rbs read_text_file: (^(ACP::Types::ReadTextFileRequest) -> (ACP::Types::ReadTextFileResponse | ACP::RequestError))?
29
49
  # @rbs write_text_file: (^(ACP::Types::WriteTextFileRequest) -> (ACP::Types::WriteTextFileResponse | ACP::RequestError))?
50
+ # @rbs create_terminal: (^(ACP::Types::CreateTerminalRequest) -> (ACP::Types::CreateTerminalResponse | ACP::RequestError))?
51
+ # @rbs terminal_output: (^(ACP::Types::TerminalOutputRequest) -> (ACP::Types::TerminalOutputResponse | ACP::RequestError))?
52
+ # @rbs wait_for_terminal_exit: (^(ACP::Types::WaitForTerminalExitRequest) ->
53
+ # (ACP::Types::WaitForTerminalExitResponse | ACP::RequestError))?
54
+ # @rbs kill_terminal: (^(ACP::Types::KillTerminalRequest) -> (ACP::Types::KillTerminalResponse | ACP::RequestError))?
55
+ # @rbs release_terminal: (^(ACP::Types::ReleaseTerminalRequest) -> (ACP::Types::ReleaseTerminalResponse | ACP::RequestError))?
30
56
  # @rbs updates: ACP::ClientConnection::_UpdateHandler
57
+ # @rbs extension_requests: Hash[String, ^(untyped) -> untyped]
58
+ # @rbs extension_notifications: Hash[String, ^(untyped) -> void]
31
59
  # @rbs logger: ACP::Transport::_Logger
32
60
  # @rbs return: void
33
- def initialize: (transport: ACP::AgentConnection::_Transport, permission: ACP::ClientConnection::_PermissionHandler, ?read_text_file: (^(ACP::Types::ReadTextFileRequest) -> (ACP::Types::ReadTextFileResponse | ACP::RequestError))?, ?write_text_file: (^(ACP::Types::WriteTextFileRequest) -> (ACP::Types::WriteTextFileResponse | ACP::RequestError))?, ?updates: ACP::ClientConnection::_UpdateHandler, ?logger: ACP::Transport::_Logger) -> void
61
+ def initialize: (transport: ACP::AgentConnection::_Transport, permission: ACP::ClientConnection::_PermissionHandler, ?read_text_file: (^(ACP::Types::ReadTextFileRequest) -> (ACP::Types::ReadTextFileResponse | ACP::RequestError))?, ?write_text_file: (^(ACP::Types::WriteTextFileRequest) -> (ACP::Types::WriteTextFileResponse | ACP::RequestError))?, ?create_terminal: (^(ACP::Types::CreateTerminalRequest) -> (ACP::Types::CreateTerminalResponse | ACP::RequestError))?, ?terminal_output: (^(ACP::Types::TerminalOutputRequest) -> (ACP::Types::TerminalOutputResponse | ACP::RequestError))?, ?wait_for_terminal_exit: (^(ACP::Types::WaitForTerminalExitRequest) -> (ACP::Types::WaitForTerminalExitResponse | ACP::RequestError))?, ?kill_terminal: (^(ACP::Types::KillTerminalRequest) -> (ACP::Types::KillTerminalResponse | ACP::RequestError))?, ?release_terminal: (^(ACP::Types::ReleaseTerminalRequest) -> (ACP::Types::ReleaseTerminalResponse | ACP::RequestError))?, ?updates: ACP::ClientConnection::_UpdateHandler, ?extension_requests: Hash[String, ^(untyped) -> untyped], ?extension_notifications: Hash[String, ^(untyped) -> void], ?logger: ACP::Transport::_Logger) -> void
34
62
 
35
63
  # @rbs return: Thread
36
64
  def start: () -> Thread
@@ -75,6 +103,11 @@ class ACP::ClientConnection
75
103
  # @rbs return: ACP::Types::DeleteSessionResponse | ACP::RequestError
76
104
  def session_delete: (ACP::Types::DeleteSessionRequest request) -> (ACP::Types::DeleteSessionResponse | ACP::RequestError)
77
105
 
106
+ # The spec requires a pending session/request_permission to be answered with
107
+ # the cancelled outcome once the turn is cancelled, so every queue registered
108
+ # for the session gets one before this returns. The notification goes first
109
+ # so the wire order is cancel, then the cancelled replies.
110
+ #
78
111
  # @rbs notification: ACP::Types::CancelNotification
79
112
  # @rbs return: void
80
113
  def session_cancel: (ACP::Types::CancelNotification notification) -> void
@@ -98,6 +131,20 @@ class ACP::ClientConnection
98
131
  # @rbs return: ACP::Types::LogoutResponse | ACP::RequestError
99
132
  def logout: (ACP::Types::LogoutRequest request) -> (ACP::Types::LogoutResponse | ACP::RequestError)
100
133
 
134
+ # Sends an extension request, keyed by its raw `_`-prefixed wire name, and
135
+ # returns the agent's reply as-is: extension methods have no schema to
136
+ # parse the result into.
137
+ #
138
+ # @rbs method: String
139
+ # @rbs params: untyped
140
+ # @rbs return: (Hash[String, untyped] | ACP::RequestError)
141
+ def ext_request: (String method, ?untyped params) -> (Hash[String, untyped] | ACP::RequestError)
142
+
143
+ # @rbs method: String
144
+ # @rbs params: untyped
145
+ # @rbs return: void
146
+ def ext_notify: (String method, ?untyped params) -> void
147
+
101
148
  private
102
149
 
103
150
  # @rbs type: ACP::AgentConnection::_Parser
@@ -124,10 +171,24 @@ class ACP::ClientConnection
124
171
  # @rbs return: void
125
172
  def dispatch: (untyped params) -> void
126
173
 
174
+ # The handler runs on its own thread so a cancelled request is answered
175
+ # without waiting for it, and whatever it returns after that is pushed to a
176
+ # queue nobody reads.
177
+ #
127
178
  # @rbs params: untyped
128
179
  # @rbs return: (ACP::Types::RequestPermissionResponse | ACP::RequestError)
129
180
  def request_permission: (untyped params) -> (ACP::Types::RequestPermissionResponse | ACP::RequestError)
130
181
 
182
+ # @rbs session_id: String
183
+ # @rbs replies: Thread::Queue
184
+ # @rbs return: void
185
+ def register_permission: (String session_id, Thread::Queue replies) -> void
186
+
187
+ # @rbs session_id: String
188
+ # @rbs replies: Thread::Queue
189
+ # @rbs return: void
190
+ def unregister_permission: (String session_id, Thread::Queue replies) -> void
191
+
131
192
  # The routes are registered at start, before connect records the advertised
132
193
  # capabilities, so each one answers -32601 until then.
133
194
  #
@@ -139,9 +200,42 @@ class ACP::ClientConnection
139
200
  # @rbs return: (ACP::Types::WriteTextFileResponse | ACP::RequestError)
140
201
  def write_text_file: (untyped params) -> (ACP::Types::WriteTextFileResponse | ACP::RequestError)
141
202
 
203
+ # The terminal routes are registered at start, before connect records the
204
+ # advertised capability, so each one answers -32601 until then.
205
+ #
206
+ # @rbs params: untyped
207
+ # @rbs return: (ACP::Types::CreateTerminalResponse | ACP::RequestError)
208
+ def create_terminal: (untyped params) -> (ACP::Types::CreateTerminalResponse | ACP::RequestError)
209
+
210
+ # @rbs params: untyped
211
+ # @rbs return: (ACP::Types::TerminalOutputResponse | ACP::RequestError)
212
+ def terminal_output: (untyped params) -> (ACP::Types::TerminalOutputResponse | ACP::RequestError)
213
+
214
+ # The handler may block for as long as the command runs, so the transport
215
+ # must serve each request on its own thread for this not to hold up others.
216
+ #
217
+ # @rbs params: untyped
218
+ # @rbs return: (ACP::Types::WaitForTerminalExitResponse | ACP::RequestError)
219
+ def wait_for_terminal_exit: (untyped params) -> (ACP::Types::WaitForTerminalExitResponse | ACP::RequestError)
220
+
221
+ # @rbs params: untyped
222
+ # @rbs return: (ACP::Types::KillTerminalResponse | ACP::RequestError)
223
+ def kill_terminal: (untyped params) -> (ACP::Types::KillTerminalResponse | ACP::RequestError)
224
+
225
+ # @rbs params: untyped
226
+ # @rbs return: (ACP::Types::ReleaseTerminalResponse | ACP::RequestError)
227
+ def release_terminal: (untyped params) -> (ACP::Types::ReleaseTerminalResponse | ACP::RequestError)
228
+
142
229
  # The fs capabilities initialize advertises that no injected handler serves.
143
230
  #
144
231
  # @rbs capabilities: ACP::Types::FileSystemCapabilities?
145
232
  # @rbs return: Array[String]
146
233
  def unserved_fs_methods: (ACP::Types::FileSystemCapabilities? capabilities) -> Array[String]
234
+
235
+ # The terminal capabilities initialize advertises that no injected handler
236
+ # serves.
237
+ #
238
+ # @rbs capabilities: bool?
239
+ # @rbs return: Array[String]
240
+ def unserved_terminal_methods: (bool? capabilities) -> Array[String]
147
241
  end
@@ -0,0 +1,9 @@
1
+ # Generated from lib/acp/extensions.rb with RBS::Inline
2
+
3
+ # Shared by the connections' handler maps and send paths, so a plain method
4
+ # name cannot slip into the extension routes.
5
+ module ACP::Extensions
6
+ # @rbs name: String
7
+ # @rbs return: void
8
+ def validate_name: (String name) -> void
9
+ end
@@ -44,8 +44,9 @@ class ACP::RequestError
44
44
  # @rbs return: ACP::RequestError
45
45
  def self.invalid_request: () -> ACP::RequestError
46
46
 
47
+ # @rbs method: String?
47
48
  # @rbs return: ACP::RequestError
48
- def self.method_not_found: () -> ACP::RequestError
49
+ def self.method_not_found: (?String? method) -> ACP::RequestError
49
50
 
50
51
  # @rbs return: ACP::RequestError
51
52
  def self.invalid_params: () -> ACP::RequestError
@@ -62,6 +62,14 @@ class ACP::Transport::Stdio
62
62
  # @rbs return: void
63
63
  def route: (Hash[String, untyped] message, Hash[String, _Handler] requests, Hash[String, _NotificationHandler] notifications) -> void
64
64
 
65
+ # JSON-RPC 2.0 says an error reply echoes the request id whenever it can be
66
+ # detected; ids outside the spec's String/Number/Null types are not, so
67
+ # those replies keep id null.
68
+ #
69
+ # @rbs message: Hash[String, untyped]
70
+ # @rbs return: untyped
71
+ def detectable_id: (Hash[String, untyped] message) -> untyped
72
+
65
73
  # @rbs line: String
66
74
  # @rbs return: Hash[String, untyped] | ACP::RequestError
67
75
  def parse: (String line) -> (Hash[String, untyped] | ACP::RequestError)
@@ -69,8 +77,9 @@ class ACP::Transport::Stdio
69
77
  # @rbs handler: _Handler?
70
78
  # @rbs id: untyped
71
79
  # @rbs params: untyped
80
+ # @rbs method: String
72
81
  # @rbs return: void
73
- def serve: (_Handler? handler, untyped id, untyped params) -> void
82
+ def serve: (_Handler? handler, untyped id, untyped params, String method) -> void
74
83
 
75
84
  # For code with no reply to carry an error: a notification handler, or a
76
85
  # Reply's after once the reply is on the wire. An exception must not kill
@@ -82,7 +91,9 @@ class ACP::Transport::Stdio
82
91
  def quietly: (String label) { () -> void } -> void
83
92
 
84
93
  # Handlers are application code at the protocol boundary: an exception
85
- # becomes an error response instead of killing the thread serving it.
94
+ # becomes an error response instead of killing the thread serving it. The
95
+ # exception is only logged, not sent: its message can carry paths, SQL or
96
+ # anything else the handler interpolated, which must not reach the peer.
86
97
  #
87
98
  # @rbs handler: _Handler
88
99
  # @rbs params: untyped
@@ -81,6 +81,8 @@ class ACP::AgentConnection
81
81
  def release_terminal: (ACP::Types::ReleaseTerminalRequest) -> (ACP::Types::ReleaseTerminalResponse | ACP::RequestError)
82
82
  def create_elicitation: (ACP::Types::CreateElicitationRequest request) -> (ACP::Types::CreateElicitationResponse | ACP::RequestError)
83
83
  def complete_elicitation: (ACP::Types::CompleteElicitationNotification notification) -> void
84
+ def ext_request: (String method, ?untyped params) -> (Hash[String, untyped] | ACP::RequestError)
85
+ def ext_notify: (String method, ?untyped params) -> void
84
86
  def capabilities: () -> ACP::Types::ClientCapabilities?
85
87
  end
86
88
 
@@ -0,0 +1,6 @@
1
+ # rbs-inline does not emit `extend self`, so without this reopen the module's
2
+ # methods aren't visible as singleton methods.
3
+
4
+ module ACP::Extensions
5
+ extend ::ACP::Extensions
6
+ end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: acp-sdk
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.5.0
4
+ version: 0.7.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jake Bottrall
@@ -44,6 +44,7 @@ files:
44
44
  - lib/acp/agent_connection/optional_method/resume_session.rb
45
45
  - lib/acp/agent_process.rb
46
46
  - lib/acp/client_connection.rb
47
+ - lib/acp/extensions.rb
47
48
  - lib/acp/request_error.rb
48
49
  - lib/acp/sdk.rb
49
50
  - lib/acp/transport.rb
@@ -259,6 +260,7 @@ files:
259
260
  - sig/generated/acp/agent_connection/optional_method/resume_session.rbs
260
261
  - sig/generated/acp/agent_process.rbs
261
262
  - sig/generated/acp/client_connection.rbs
263
+ - sig/generated/acp/extensions.rbs
262
264
  - sig/generated/acp/request_error.rbs
263
265
  - sig/generated/acp/sdk.rbs
264
266
  - sig/generated/acp/transport.rbs
@@ -472,6 +474,7 @@ files:
472
474
  - sig/manual/acp/agent_connection/optional_method/resume_session.rbs
473
475
  - sig/manual/acp/agent_process.rbs
474
476
  - sig/manual/acp/client_connection.rbs
477
+ - sig/manual/acp/extensions.rbs
475
478
  - sig/manual/acp/transport.rbs
476
479
  - sig/manual/acp/transport/stdio.rbs
477
480
  homepage: https://github.com/bottrall/acp-sdk