acp-sdk 0.2.0 → 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: 4fcba010891683b66eaf2fd2ae8bf9ae27e2aeb13b76666cc8c4478dfb7c4e9a
4
- data.tar.gz: 5045a772ca2f080a7566ad3556a16087b3d15f4cbd39c3ed0ee5ae4dfe0ab521
3
+ metadata.gz: 59dca9a568751a3b4c29c1ff4afdd9ad12e997689d5eb3a4782f2a4c5ced81b0
4
+ data.tar.gz: 99bde6bbc81d16012a6d9b3654bf15f15a35a40713f3d6dbbe686557f182ac50
5
5
  SHA512:
6
- metadata.gz: c1a7eb1a2fc03be127272d8e3a97564c014691d1b582bd317f1da7b7022f250d603a09a811ef1271a9133e2bcf2fa5a6c7cde1f7d7ffe8c08a1f37fa28080d71
7
- data.tar.gz: 4d006a52359dfdde15978220b83b9d7b10251b44c73a66c43be7a4259c0646a559b39ee05b09b1010e6b0108a95945751ad2cf0ffea4c6f9f1be041a0d4efbfa
6
+ metadata.gz: f803ca1e9d3dae3b791cebe930d49c221b2cd3f68df6cc5734bc3e40103af0d101db4447851596c2f6834b855f08a110c24455b06c517aa62e51b95a23cbdfd2
7
+ data.tar.gz: 844f53d23060359a77314f196fdeab023a7ded7c67fdea9f1a75b80bb28db3efb2421977001c13b3ec37f84479f6b9bf816e53a4a8da92c89bc58ee211b88ab8
data/CHANGELOG.md CHANGED
@@ -1,5 +1,13 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.3.0](https://github.com/bottrall/acp-sdk/compare/v0.2.0...v0.3.0) (2026-10-02)
4
+
5
+
6
+ ### Features
7
+
8
+ * named protocol error codes ([#52](https://github.com/bottrall/acp-sdk/issues/52)) ([0a81700](https://github.com/bottrall/acp-sdk/commit/0a81700e3fa095279a77e2f161a7d6369c46792f))
9
+ * spawn an agent process for ACP::ClientConnection ([#54](https://github.com/bottrall/acp-sdk/issues/54)) ([e01d2d2](https://github.com/bottrall/acp-sdk/commit/e01d2d280e9cfd08c203f6dccd1ce602979e21c8))
10
+
3
11
  ## [0.2.0](https://github.com/bottrall/acp-sdk/compare/v0.1.0...v0.2.0) (2026-09-30)
4
12
 
5
13
 
data/README.md CHANGED
@@ -49,40 +49,53 @@ connection.start.join
49
49
  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; `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
- - Each request method returns its response type or an `ACP::Transport::ResponseError`, which is sent as the error reply. Params that do not match the schema are answered with `-32602` before the agent sees them.
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
53
  - `cancel` runs on the transport's reader thread, so it must return quickly: set a flag and let the prompt notice it.
54
54
  - After a cancel, the agent must itself end the turn with `stopReason: cancelled`. `ACP::AgentConnection` does not enforce it.
55
55
  - `client.capabilities` is `nil` until the client sends `initialize`.
56
- - `client.read_text_file` and `client.write_text_file` take an `ACP::Types::ReadTextFileRequest` or `ACP::Types::WriteTextFileRequest` and return the response type or an `ACP::Transport::ResponseError`. Unless `client.capabilities` advertises `fs.read_text_file` or `fs.write_text_file`, they return `-32601` without sending anything to the client.
57
- - `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::Transport::ResponseError`. Unless `client.capabilities` advertises `terminal`, they return `-32601` without sending anything to the client. The agent must release every terminal it creates.
56
+ - `client.read_text_file` and `client.write_text_file` take an `ACP::Types::ReadTextFileRequest` or `ACP::Types::WriteTextFileRequest` and return the response type or an `ACP::RequestError`. Unless `client.capabilities` advertises `fs.read_text_file` or `fs.write_text_file`, they return `-32601` without sending anything to the client.
57
+ - `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
58
 
59
59
  [`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` and `session/list`.
60
60
 
61
61
  ## Driving an agent
62
62
 
63
- `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::Transport::ResponseError`. `connect` sends `initialize`, since Ruby reserves that name for the constructor.
63
+ `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.
64
+
65
+ `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.
64
66
 
65
67
  ```ruby
66
68
  require 'acp/sdk'
67
- require 'open3'
68
69
 
69
- stdin, stdout, = Open3.popen2('my-agent')
70
+ response = ACP::AgentProcess.spawn(
71
+ 'my-agent', '--flag',
72
+ env: { 'API_KEY' => '...' },
73
+ cwd: Dir.pwd,
74
+ stderr: :capture,
75
+ permission: ->(request) { ask_the_user(request) },
76
+ updates: ->(notification) { show(notification) }
77
+ ) do |connection|
78
+ connection.connect(ACP::Types::InitializeRequest.new(protocol_version: 1))
79
+ session = connection.session_new(ACP::Types::NewSessionRequest.new(cwd: Dir.pwd, mcp_servers: []))
80
+ prompt = [ACP::Types::ContentBlock::Text.new(text: 'hello')]
81
+ connection.session_prompt(ACP::Types::PromptRequest.new(session_id: session.session_id, prompt:)) do |update|
82
+ print update.content.text if update.is_a?(ACP::Types::SessionUpdate::AgentMessageChunk)
83
+ end
84
+ end
85
+ ```
86
+
87
+ Any transport over a pair of IOs works too, so the connection can still be wired by hand when the agent is not a child process:
88
+
89
+ ```ruby
70
90
  connection = ACP::ClientConnection.new(
71
- transport: ACP::Transport::Stdio.new(input: stdout, output: stdin),
91
+ transport: ACP::Transport::Stdio.new(input: some_io, output: other_io),
72
92
  permission: ->(request) { ask_the_user(request) },
73
93
  updates: ->(notification) { show(notification) }
74
94
  )
75
- connection.start
76
- connection.connect(ACP::Types::InitializeRequest.new(protocol_version: 1))
77
- session = connection.session_new(ACP::Types::NewSessionRequest.new(cwd: Dir.pwd, mcp_servers: []))
78
- prompt = [ACP::Types::ContentBlock::Text.new(text: 'hello')]
79
- response = connection.session_prompt(ACP::Types::PromptRequest.new(session_id: session.session_id, prompt:)) do |update|
80
- print update.content.text if update.is_a?(ACP::Types::SessionUpdate::AgentMessageChunk)
81
- end
82
95
  ```
83
96
 
84
97
  - `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.
85
- - `permission` answers `session/request_permission` with an `ACP::Types::RequestPermissionResponse` or an `ACP::Transport::ResponseError`. It runs on its own thread, so it may block while the user decides, and `session_cancel` can be sent meanwhile.
98
+ - `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.
86
99
 
87
100
  ## Contributing
88
101
 
@@ -31,53 +31,53 @@ class ACP::AgentConnection::Client
31
31
  # closes.
32
32
  #
33
33
  # @rbs request: ACP::Types::RequestPermissionRequest
34
- # @rbs return: ACP::Types::RequestPermissionResponse | ACP::Transport::ResponseError
34
+ # @rbs return: ACP::Types::RequestPermissionResponse | ACP::RequestError
35
35
  def request_permission(request)
36
36
  call('session/request_permission', ACP::Types::RequestPermissionResponse, request)
37
37
  end
38
38
 
39
39
  # @rbs request: ACP::Types::ReadTextFileRequest
40
- # @rbs return: ACP::Types::ReadTextFileResponse | ACP::Transport::ResponseError
40
+ # @rbs return: ACP::Types::ReadTextFileResponse | ACP::RequestError
41
41
  def read_text_file(request)
42
- return ACP::Transport::Stdio::METHOD_NOT_FOUND unless capabilities&.fs&.read_text_file
42
+ return unadvertised('fs.readTextFile') unless capabilities&.fs&.read_text_file
43
43
 
44
44
  call('fs/read_text_file', ACP::Types::ReadTextFileResponse, request)
45
45
  end
46
46
 
47
47
  # @rbs request: ACP::Types::WriteTextFileRequest
48
- # @rbs return: ACP::Types::WriteTextFileResponse | ACP::Transport::ResponseError
48
+ # @rbs return: ACP::Types::WriteTextFileResponse | ACP::RequestError
49
49
  def write_text_file(request)
50
- return ACP::Transport::Stdio::METHOD_NOT_FOUND unless capabilities&.fs&.write_text_file
50
+ return unadvertised('fs.writeTextFile') unless capabilities&.fs&.write_text_file
51
51
 
52
52
  call('fs/write_text_file', ACP::Types::WriteTextFileResponse, request)
53
53
  end
54
54
 
55
55
  # @rbs request: ACP::Types::CreateTerminalRequest
56
- # @rbs return: ACP::Types::CreateTerminalResponse | ACP::Transport::ResponseError
56
+ # @rbs return: ACP::Types::CreateTerminalResponse | ACP::RequestError
57
57
  def create_terminal(request)
58
58
  terminal_call('terminal/create', ACP::Types::CreateTerminalResponse, request)
59
59
  end
60
60
 
61
61
  # @rbs request: ACP::Types::TerminalOutputRequest
62
- # @rbs return: ACP::Types::TerminalOutputResponse | ACP::Transport::ResponseError
62
+ # @rbs return: ACP::Types::TerminalOutputResponse | ACP::RequestError
63
63
  def terminal_output(request)
64
64
  terminal_call('terminal/output', ACP::Types::TerminalOutputResponse, request)
65
65
  end
66
66
 
67
67
  # @rbs request: ACP::Types::WaitForTerminalExitRequest
68
- # @rbs return: ACP::Types::WaitForTerminalExitResponse | ACP::Transport::ResponseError
68
+ # @rbs return: ACP::Types::WaitForTerminalExitResponse | ACP::RequestError
69
69
  def wait_for_terminal_exit(request)
70
70
  terminal_call('terminal/wait_for_exit', ACP::Types::WaitForTerminalExitResponse, request)
71
71
  end
72
72
 
73
73
  # @rbs request: ACP::Types::KillTerminalRequest
74
- # @rbs return: ACP::Types::KillTerminalResponse | ACP::Transport::ResponseError
74
+ # @rbs return: ACP::Types::KillTerminalResponse | ACP::RequestError
75
75
  def kill_terminal(request)
76
76
  terminal_call('terminal/kill', ACP::Types::KillTerminalResponse, request)
77
77
  end
78
78
 
79
79
  # @rbs request: ACP::Types::ReleaseTerminalRequest
80
- # @rbs return: ACP::Types::ReleaseTerminalResponse | ACP::Transport::ResponseError
80
+ # @rbs return: ACP::Types::ReleaseTerminalResponse | ACP::RequestError
81
81
  def release_terminal(request)
82
82
  terminal_call('terminal/release', ACP::Types::ReleaseTerminalResponse, request)
83
83
  end
@@ -90,7 +90,9 @@ class ACP::AgentConnection::Client
90
90
  # @rbs return: untyped
91
91
  def call(rpc_method, type, request)
92
92
  result = @peer.request(rpc_method, request.to_h)
93
- result.error || type.from_h(result.value)
93
+ return result if result.is_a?(ACP::RequestError)
94
+
95
+ type.from_h(result)
94
96
  end
95
97
 
96
98
  # @rbs rpc_method: String
@@ -98,8 +100,19 @@ class ACP::AgentConnection::Client
98
100
  # @rbs request: ACP::AgentConnection::_Response
99
101
  # @rbs return: untyped
100
102
  def terminal_call(rpc_method, type, request)
101
- return ACP::Transport::Stdio::METHOD_NOT_FOUND unless capabilities&.terminal
103
+ return unadvertised('terminal') unless capabilities&.terminal
102
104
 
103
105
  call(rpc_method, type, request)
104
106
  end
107
+
108
+ # Same method-not-found code a peer would send, but with a message that says
109
+ # the refusal was local, so logs can tell the two cases apart.
110
+ #
111
+ # @rbs capability: String
112
+ # @rbs return: ACP::RequestError
113
+ def unadvertised(capability)
114
+ ACP::RequestError.new(
115
+ code: ACP::RequestError::METHOD_NOT_FOUND, message: "Client does not advertise #{capability}"
116
+ )
117
+ end
105
118
  end
@@ -6,7 +6,6 @@ class ACP::AgentConnection
6
6
  # @rbs @factory: ^(ACP::AgentConnection::Client) -> ACP::AgentConnection::_Agent
7
7
 
8
8
  PROTOCOL_VERSION = 1 #: Integer
9
- INVALID_PARAMS = ACP::Transport::ResponseError.new(code: -32_602, message: 'Invalid params') #: ACP::Transport::ResponseError
10
9
  OPTIONAL = [
11
10
  ACP::AgentConnection::OptionalMethod::LoadSession,
12
11
  ACP::AgentConnection::OptionalMethod::ListSessions,
@@ -56,26 +55,24 @@ class ACP::AgentConnection
56
55
 
57
56
  # @rbs agent: ACP::AgentConnection::_Agent
58
57
  # @rbs client: ACP::AgentConnection::Client
59
- # @rbs return: Hash[String, ^(untyped) -> (ACP::Transport::Result | ACP::Transport::Reply)]
58
+ # @rbs return: Hash[String, ^(untyped) -> (ACP::AgentConnection::_Response | ACP::RequestError | ACP::Transport::Reply)]
60
59
  def requests(agent, client)
61
60
  # Safe: start drops the optional handlers initialize does not advertise
62
61
  # or the agent does not define, and checks the agent defines the rest.
63
62
  full = agent #: ACP::AgentConnection::_FullAgent
64
63
  {
65
64
  'initialize' => route(ACP::Types::InitializeRequest) { |request| connect(client, request) },
66
- 'authenticate' => route(ACP::Types::AuthenticateRequest) { |request| respond(full.authenticate(request)) },
65
+ 'authenticate' => route(ACP::Types::AuthenticateRequest) { |request| full.authenticate(request) },
67
66
  'session/new' => route(ACP::Types::NewSessionRequest) { |request| new_session(agent, request) },
68
- 'session/prompt' => route(ACP::Types::PromptRequest) { |request| respond(agent.prompt(request)) },
69
- 'session/load' => route(ACP::Types::LoadSessionRequest) { |request| respond(full.load_session(request)) },
70
- 'session/list' => route(ACP::Types::ListSessionsRequest) { |request| respond(full.list_sessions(request)) },
71
- 'session/resume' => route(ACP::Types::ResumeSessionRequest) { |request| respond(full.resume_session(request)) },
72
- 'session/close' => route(ACP::Types::CloseSessionRequest) { |request| respond(full.close_session(request)) },
73
- 'session/delete' => route(ACP::Types::DeleteSessionRequest) { |request| respond(full.delete_session(request)) },
74
- 'session/set_mode' => route(ACP::Types::SetSessionModeRequest) do |request|
75
- respond(full.change_session_mode(request))
76
- end,
67
+ 'session/prompt' => route(ACP::Types::PromptRequest) { |request| agent.prompt(request) },
68
+ 'session/load' => route(ACP::Types::LoadSessionRequest) { |request| full.load_session(request) },
69
+ 'session/list' => route(ACP::Types::ListSessionsRequest) { |request| full.list_sessions(request) },
70
+ 'session/resume' => route(ACP::Types::ResumeSessionRequest) { |request| full.resume_session(request) },
71
+ 'session/close' => route(ACP::Types::CloseSessionRequest) { |request| full.close_session(request) },
72
+ 'session/delete' => route(ACP::Types::DeleteSessionRequest) { |request| full.delete_session(request) },
73
+ 'session/set_mode' => route(ACP::Types::SetSessionModeRequest) { |request| full.change_session_mode(request) },
77
74
  'session/set_config_option' => route(ACP::Types::SetSessionConfigOptionRequest) do |request|
78
- respond(full.change_session_config_option(request))
75
+ full.change_session_config_option(request)
79
76
  end
80
77
  }
81
78
  end
@@ -89,33 +86,24 @@ class ACP::AgentConnection
89
86
  # Generated from_h raises on a missing key or a value of the wrong shape.
90
87
  #
91
88
  # @rbs type: ACP::AgentConnection::_Parser
92
- # @rbs &handle: (untyped) -> (ACP::Transport::Result | ACP::Transport::Reply)
93
- # @rbs return: ^(untyped) -> (ACP::Transport::Result | ACP::Transport::Reply)
89
+ # @rbs &handle: (untyped) -> (ACP::AgentConnection::_Response | ACP::RequestError | ACP::Transport::Reply)
90
+ # @rbs return: ^(untyped) -> (ACP::AgentConnection::_Response | ACP::RequestError | ACP::Transport::Reply)
94
91
  def route(type, &)
95
92
  lambda do |params|
96
93
  request = type.from_h(params)
97
94
  rescue KeyError, TypeError, NoMethodError
98
- ACP::Transport::Result.error(INVALID_PARAMS)
95
+ ACP::RequestError.invalid_params
99
96
  else
100
97
  yield(request)
101
98
  end
102
99
  end
103
100
 
104
- # @rbs response: ACP::AgentConnection::_Response | ACP::Transport::ResponseError
105
- # @rbs return: ACP::Transport::Result
106
- def respond(response)
107
- case response
108
- when ACP::Transport::ResponseError then ACP::Transport::Result.error(response)
109
- else ACP::Transport::Result.ok(response.to_h)
110
- end
111
- end
112
-
113
101
  # @rbs client: ACP::AgentConnection::Client
114
102
  # @rbs request: ACP::Types::InitializeRequest
115
- # @rbs return: ACP::Transport::Result
103
+ # @rbs return: ACP::Types::InitializeResponse
116
104
  def connect(client, request)
117
105
  client.capabilities = request.client_capabilities || ACP::Types::ClientCapabilities.new
118
- respond(@initialize_response)
106
+ @initialize_response
119
107
  end
120
108
 
121
109
  # session_created runs after the reply because the client must know the
@@ -123,13 +111,12 @@ class ACP::AgentConnection
123
111
  #
124
112
  # @rbs agent: ACP::AgentConnection::_Agent
125
113
  # @rbs request: ACP::Types::NewSessionRequest
126
- # @rbs return: ACP::Transport::Result | ACP::Transport::Reply
114
+ # @rbs return: (ACP::Types::NewSessionResponse | ACP::RequestError | ACP::Transport::Reply)
127
115
  def new_session(agent, request)
128
116
  response = agent.new_session(request)
129
- result = respond(response)
130
- return result unless response.is_a?(ACP::Types::NewSessionResponse) && agent.respond_to?(:session_created)
117
+ return response unless response.is_a?(ACP::Types::NewSessionResponse) && agent.respond_to?(:session_created)
131
118
 
132
119
  hook = agent #: ACP::AgentConnection::_Agent & ACP::AgentConnection::_SessionCreated
133
- ACP::Transport::Reply.new(result, after: -> { hook.session_created(response) })
120
+ ACP::Transport::Reply.new(response, after: -> { hook.session_created(response) })
134
121
  end
135
122
  end
@@ -0,0 +1,119 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'open3'
4
+
5
+ # A child agent process and the ACP::ClientConnection wired to its stdio.
6
+ # Spawn one with ACP::AgentProcess.spawn.
7
+ class ACP::AgentProcess
8
+ # @rbs @connection: ACP::ClientConnection
9
+ # @rbs @reader: Thread
10
+ # @rbs @pid: Integer
11
+ # @rbs @stdin: IO
12
+ # @rbs @stdout: IO
13
+ # @rbs @waiter: Process::Waiter
14
+ # @rbs @err_r: IO?
15
+ # @rbs @err_reader: Thread?
16
+ # @rbs @stderr: String?
17
+
18
+ # @dynamic pid
19
+ attr_reader :pid #: Integer
20
+
21
+ # @dynamic connection
22
+ attr_reader :connection #: ACP::ClientConnection
23
+
24
+ # Child stderr captured when spawn was given stderr: :capture, else nil.
25
+ #
26
+ # @dynamic stderr
27
+ attr_reader :stderr #: String?
28
+
29
+ # Starts a command with args, env and cwd, wires its stdio to a new
30
+ # ACP::ClientConnection and starts it. With a block, yields the connection
31
+ # and this process, then terminates the child when the block exits; without
32
+ # one, returns the process and terminate is the caller's job.
33
+ #
34
+ # @rbs command: String
35
+ # @rbs *args: String
36
+ # @rbs env: Hash[String, String]?
37
+ # @rbs cwd: String?
38
+ # @rbs stderr: (:inherit | :capture)
39
+ # @rbs permission: ACP::ClientConnection::_PermissionHandler
40
+ # @rbs updates: ACP::ClientConnection::_UpdateHandler
41
+ # @rbs &block: ? (ACP::ClientConnection, ACP::AgentProcess) -> untyped
42
+ # @rbs return: (untyped | ACP::AgentProcess)
43
+ def self.spawn(
44
+ command,
45
+ *args,
46
+ permission:,
47
+ env: nil,
48
+ cwd: nil,
49
+ stderr: :inherit,
50
+ updates: ACP::ClientConnection::IGNORE,
51
+ &block
52
+ )
53
+ err_r, err_w = stderr == :capture ? IO.pipe : nil
54
+ opts = {} #: Hash[Symbol, untyped]
55
+ opts[:chdir] = cwd if cwd
56
+ opts[:err] = err_w if err_w
57
+ spawner = Open3.method(:popen2) #: _Spawner
58
+ stdin, stdout, waiter = spawner.call(*(env ? [env, command, *args] : [command, *args]), **opts)
59
+ err_w&.close
60
+ connection = ACP::ClientConnection.new(
61
+ transport: ACP::Transport::Stdio.new(input: stdout, output: stdin),
62
+ permission:,
63
+ updates:
64
+ )
65
+ process = new(connection:, reader: connection.start, stdin:, stdout:, waiter:, err_r:)
66
+ return process unless block
67
+
68
+ begin
69
+ yield connection, process
70
+ ensure
71
+ process.terminate
72
+ end
73
+ end
74
+
75
+ # Closes stdin, waits for the child to exit — sending TERM and then KILL if
76
+ # it will not — and joins the transport reader before closing the pipe it
77
+ # reads.
78
+ #
79
+ # @rbs return: void
80
+ def terminate
81
+ @stdin.close unless @stdin.closed?
82
+ @waiter.join(1) or signal('TERM')
83
+ @waiter.join(2) or signal('KILL')
84
+ @waiter.join(2)
85
+ @reader.join(1)
86
+ @stdout.close unless @stdout.closed?
87
+ @err_reader&.join(1)
88
+ @err_r&.close unless @err_r&.closed?
89
+ end
90
+
91
+ private
92
+
93
+ # @rbs connection: ACP::ClientConnection
94
+ # @rbs reader: Thread
95
+ # @rbs stdin: IO
96
+ # @rbs stdout: IO
97
+ # @rbs waiter: Process::Waiter
98
+ # @rbs err_r: IO?
99
+ # @rbs return: void
100
+ def initialize(connection:, reader:, stdin:, stdout:, waiter:, err_r:)
101
+ @connection = connection
102
+ @reader = reader
103
+ @pid = waiter.pid
104
+ @stdin = stdin
105
+ @stdout = stdout
106
+ @waiter = waiter
107
+ @err_r = err_r
108
+ @stderr = err_r ? +'' : nil
109
+ @err_reader = Thread.new { err_r.each_line { |line| @stderr&.concat(line) } } if err_r
110
+ end
111
+
112
+ # @rbs sig: String
113
+ # @rbs return: void
114
+ def signal(sig)
115
+ Process.kill(sig, @pid)
116
+ rescue Errno::ESRCH
117
+ nil
118
+ end
119
+ end
@@ -33,33 +33,33 @@ class ACP::ClientConnection
33
33
  # sent by connect.
34
34
  #
35
35
  # @rbs request: ACP::Types::InitializeRequest
36
- # @rbs return: ACP::Types::InitializeResponse | ACP::Transport::ResponseError
36
+ # @rbs return: ACP::Types::InitializeResponse | ACP::RequestError
37
37
  def connect(request)
38
38
  parse(ACP::Types::InitializeResponse, @transport.request('initialize', request.to_h))
39
39
  end
40
40
 
41
41
  # @rbs request: ACP::Types::NewSessionRequest
42
- # @rbs return: ACP::Types::NewSessionResponse | ACP::Transport::ResponseError
42
+ # @rbs return: ACP::Types::NewSessionResponse | ACP::RequestError
43
43
  def session_new(request)
44
44
  parse(ACP::Types::NewSessionResponse, @transport.request('session/new', request.to_h))
45
45
  end
46
46
 
47
47
  # @rbs request: ACP::Types::PromptRequest
48
48
  # @rbs &block: (ACP::Types::SessionUpdate::t) -> void
49
- # @rbs return: ACP::Types::PromptResponse | ACP::Transport::ResponseError
49
+ # @rbs return: ACP::Types::PromptResponse | ACP::RequestError
50
50
  def session_prompt(request, &)
51
51
  parse(ACP::Types::PromptResponse, stream(request.session_id, 'session/prompt', request.to_h, &))
52
52
  end
53
53
 
54
54
  # @rbs request: ACP::Types::LoadSessionRequest
55
55
  # @rbs &block: (ACP::Types::SessionUpdate::t) -> void
56
- # @rbs return: ACP::Types::LoadSessionResponse | ACP::Transport::ResponseError
56
+ # @rbs return: ACP::Types::LoadSessionResponse | ACP::RequestError
57
57
  def session_load(request, &)
58
58
  parse(ACP::Types::LoadSessionResponse, stream(request.session_id, 'session/load', request.to_h, &))
59
59
  end
60
60
 
61
61
  # @rbs request: ACP::Types::ListSessionsRequest
62
- # @rbs return: ACP::Types::ListSessionsResponse | ACP::Transport::ResponseError
62
+ # @rbs return: ACP::Types::ListSessionsResponse | ACP::RequestError
63
63
  def session_list(request)
64
64
  parse(ACP::Types::ListSessionsResponse, @transport.request('session/list', request.to_h))
65
65
  end
@@ -73,10 +73,12 @@ class ACP::ClientConnection
73
73
  private
74
74
 
75
75
  # @rbs type: ACP::AgentConnection::_Parser
76
- # @rbs result: ACP::Transport::Result
76
+ # @rbs result: (Hash[String, untyped] | ACP::RequestError)
77
77
  # @rbs return: untyped
78
78
  def parse(type, result)
79
- result.error || type.from_h(result.value)
79
+ return result if result.is_a?(ACP::RequestError)
80
+
81
+ type.from_h(result)
80
82
  end
81
83
 
82
84
  # The request waits on its own thread so the caller's thread can yield each
@@ -87,7 +89,7 @@ class ACP::ClientConnection
87
89
  # @rbs method: String
88
90
  # @rbs params: Hash[String, untyped]
89
91
  # @rbs &block: (ACP::Types::SessionUpdate::t) -> void
90
- # @rbs return: ACP::Transport::Result
92
+ # @rbs return: (Hash[String, untyped] | ACP::RequestError)
91
93
  def stream(session_id, method, params)
92
94
  queue = Thread::Queue.new
93
95
  @lock.synchronize { @streams[session_id] = queue }
@@ -114,16 +116,12 @@ class ACP::ClientConnection
114
116
  end
115
117
 
116
118
  # @rbs params: untyped
117
- # @rbs return: ACP::Transport::Result
119
+ # @rbs return: (ACP::Types::RequestPermissionResponse | ACP::RequestError)
118
120
  def request_permission(params)
119
121
  request = ACP::Types::RequestPermissionRequest.from_h(params)
120
122
  rescue KeyError, TypeError, NoMethodError
121
- ACP::Transport::Result.error(ACP::AgentConnection::INVALID_PARAMS)
123
+ ACP::RequestError.invalid_params
122
124
  else
123
- response = @permission.call(request)
124
- case response
125
- when ACP::Transport::ResponseError then ACP::Transport::Result.error(response)
126
- else ACP::Transport::Result.ok(response.to_h)
127
- end
125
+ @permission.call(request)
128
126
  end
129
127
  end
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ # The schema's JSON-RPC Error object, named after the official SDKs'
4
+ # RequestError: built by handlers and local refusals, and decoded from a
5
+ # peer's error replies by the transport.
6
+ class ACP::RequestError
7
+ PARSE_ERROR = -32_700 #: Integer
8
+ INVALID_REQUEST = -32_600 #: Integer
9
+ METHOD_NOT_FOUND = -32_601 #: Integer
10
+ INVALID_PARAMS = -32_602 #: Integer
11
+ INTERNAL_ERROR = -32_603 #: Integer
12
+ REQUEST_CANCELLED = -32_800 #: Integer
13
+ AUTH_REQUIRED = -32_000 #: Integer
14
+ RESOURCE_NOT_FOUND = -32_002 #: Integer
15
+
16
+ # @rbs code: Integer
17
+ # @rbs message: String
18
+ # @rbs data: untyped
19
+ # @rbs return: void
20
+ def initialize(code:, message:, data: nil)
21
+ @code = code
22
+ @message = message
23
+ @data = data
24
+ freeze
25
+ end
26
+
27
+ # @dynamic code
28
+ attr_reader :code #: Integer
29
+
30
+ # @dynamic message
31
+ attr_reader :message #: String
32
+
33
+ # @dynamic data
34
+ attr_reader :data #: untyped
35
+
36
+ # @rbs return: Hash[String, untyped]
37
+ def to_h
38
+ { 'code' => code, 'message' => message, 'data' => data }.compact
39
+ end
40
+
41
+ # @rbs return: ACP::RequestError
42
+ def self.parse_error
43
+ new(code: PARSE_ERROR, message: 'Parse error')
44
+ end
45
+
46
+ # @rbs return: ACP::RequestError
47
+ def self.invalid_request
48
+ new(code: INVALID_REQUEST, message: 'Invalid request')
49
+ end
50
+
51
+ # @rbs return: ACP::RequestError
52
+ def self.method_not_found
53
+ new(code: METHOD_NOT_FOUND, message: 'Method not found')
54
+ end
55
+
56
+ # @rbs return: ACP::RequestError
57
+ def self.invalid_params
58
+ new(code: INVALID_PARAMS, message: 'Invalid params')
59
+ end
60
+
61
+ # @rbs return: ACP::RequestError
62
+ def self.internal_error
63
+ new(code: INTERNAL_ERROR, message: 'Internal error')
64
+ end
65
+
66
+ # @rbs return: ACP::RequestError
67
+ def self.request_cancelled
68
+ new(code: REQUEST_CANCELLED, message: 'Request cancelled')
69
+ end
70
+
71
+ # @rbs return: ACP::RequestError
72
+ def self.auth_required
73
+ new(code: AUTH_REQUIRED, message: 'Authentication required')
74
+ end
75
+
76
+ # @rbs uri: String?
77
+ # @rbs return: ACP::RequestError
78
+ def self.resource_not_found(uri = nil)
79
+ new(code: RESOURCE_NOT_FOUND, message: 'Resource not found', data: uri && { uri: uri })
80
+ end
81
+ end
@@ -1,15 +1,15 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # A request handler's result plus a callback the transport runs once that
4
- # result is on the wire, for messages the peer must only see after the reply.
3
+ # A request handler's outcome plus a callback the transport runs once that
4
+ # outcome is on the wire, for messages the peer must only see after the reply.
5
5
  class ACP::Transport::Reply
6
6
  # @dynamic result
7
- attr_reader :result #: ACP::Transport::Result
7
+ attr_reader :result #: ACP::Transport::Stdio::_ToH
8
8
 
9
9
  # @dynamic after
10
10
  attr_reader :after #: ^() -> void
11
11
 
12
- # @rbs result: ACP::Transport::Result
12
+ # @rbs result: ACP::Transport::Stdio::_ToH
13
13
  # @rbs after: ^() -> void
14
14
  # @rbs return: void
15
15
  def initialize(result, after:)