acp_sdk_async 0.2.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: a87705f9f7fc44a42ab713bc4122e1298c84cd5fa736bab7f605d83ee91fe493
4
+ data.tar.gz: 05f8fe85747082078abd8963111084d6e8d5fcac56c64afb5e7d4cc9dadf22dc
5
+ SHA512:
6
+ metadata.gz: '0170289fc4f146993df1a91092643774248cfd86d806cb12ef932788462c4333da1beb24c00dda4c672b0ae3b8d3a14be6c732738ecf459f02e01f170b7218eb'
7
+ data.tar.gz: 361eb5a9641e852eb48d71bce99a8ca811ba50a364b497f62e13d9490c4456b34133a9e6d4bb4336c6c34e4e9a2335affdbb932e41e249b70faabda88911768f
data/LICENSE ADDED
@@ -0,0 +1,24 @@
1
+ This is free and unencumbered software released into the public domain.
2
+
3
+ Anyone is free to copy, modify, publish, use, compile, sell, or
4
+ distribute this software, either in source code form or as a compiled
5
+ binary, for any purpose, commercial or non-commercial, and by any
6
+ means.
7
+
8
+ In jurisdictions that recognize copyright laws, the author or authors
9
+ of this software dedicate any and all copyright interest in the
10
+ software to the public domain. We make this dedication for the benefit
11
+ of the public at large and to the detriment of our heirs and
12
+ successors. We intend this dedication to be an overt act of
13
+ relinquishment in perpetuity of all present and future rights to this
14
+ software under copyright law.
15
+
16
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
17
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
18
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
19
+ IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR
20
+ OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE,
21
+ ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
22
+ OTHER DEALINGS IN THE SOFTWARE.
23
+
24
+ For more information, please refer to <https://unlicense.org>
data/README.md ADDED
@@ -0,0 +1,147 @@
1
+ # acp_sdk_async
2
+
3
+ Ruby SDK for the [Agent Client Protocol](https://agentclientprotocol.com) (ACP): typed schema models generated from the official `schema.json`, a JSON-RPC 2.0 connection over NDJSON, client- and agent-side wrappers, and helpers for spawning agents over stdio.
4
+
5
+ Requires Ruby 3.2+ and runs on [`async`](https://github.com/socketry/async), the same cooperative scheduler used by Falcon. The public API is blocking (`client.prompt(...)`) like a normal Ruby method; inside a reactor those calls yield to other tasks instead of occupying a thread.
6
+
7
+ ## Installation
8
+
9
+ ```ruby
10
+ gem "acp_sdk_async"
11
+ ```
12
+
13
+ From git:
14
+
15
+ ```ruby
16
+ gem "acp_sdk_async", git: "git@github.com:nutsoriginal/acp_sdk.git", branch: "main"
17
+ ```
18
+
19
+ ```bash
20
+ bundle install
21
+ ```
22
+
23
+ ## Client: talk to an agent over stdio
24
+
25
+ ACP calls must run inside an Async reactor. `Sync { ... }` starts one, or reuses the current task if you are already inside Falcon/`Async`.
26
+
27
+ ```ruby
28
+ require "acp_sdk_async"
29
+
30
+ S = ACP::Schema
31
+
32
+ class MyClient
33
+ def session_update(notification)
34
+ update = notification.update
35
+ print update.content.text if update.is_a?(S::AgentMessageChunk)
36
+ end
37
+
38
+ def request_permission(request)
39
+ option = request.options.find { |candidate| candidate.kind.start_with?("allow") }
40
+ return S::RequestPermissionResponse.new(outcome: S::DeniedOutcome.new) if option.nil?
41
+
42
+ S::RequestPermissionResponse.new(outcome: S::AllowedOutcome.new(option_id: option.option_id))
43
+ end
44
+ end
45
+
46
+ Sync do
47
+ process = ACP::Stdio.spawn_agent("my-agent", "--acp", cwd: Dir.pwd, stderr: :log)
48
+ client = process.connect(MyClient.new)
49
+
50
+ client.initialize_agent(client_info: S::Implementation.new(name: "my-client", version: "1.0"))
51
+ session = client.new_session(cwd: Dir.pwd)
52
+ response = client.prompt(
53
+ session_id: session.session_id,
54
+ prompt: [S::TextContentBlock.new(text: "Hello!")]
55
+ )
56
+ puts response.stop_reason
57
+
58
+ client.close
59
+ process.close
60
+ end
61
+ ```
62
+
63
+ `ACP::Client::Connection` exposes every agent method (`initialize_agent`, `authenticate`, `new_session`, `load_session`, `list_sessions`, `fork_session`, `resume_session`, `set_session_mode`, `set_session_config_option`, `prompt`, `cancel`, `logout`, `ext_method`, `ext_notification`, ...). Request arguments are keyword arguments matching the schema fields in snake_case; responses come back as typed models.
64
+
65
+ The client handler receives typed models too: `session_update(S::SessionNotification)`, `request_permission(S::RequestPermissionRequest)`, `read_text_file`, `write_text_file`, `create_terminal`, `terminal_output`, `release_terminal`, `wait_for_terminal_exit`, `kill_terminal`, `create_elicitation`, `complete_elicitation`, `ext_method(name, params)`, `ext_notification(name, params)`. Methods the handler does not implement return `method not found` to the peer.
66
+
67
+ Incoming requests run as child tasks so a handler can call back into the peer without deadlocking. Notifications are delivered in order on a single task. After `prompt` returns, queued `session/update` handlers have already finished.
68
+
69
+ ## Agent: serve the protocol over stdin/stdout
70
+
71
+ ```ruby
72
+ require "acp_sdk_async"
73
+
74
+ S = ACP::Schema
75
+
76
+ class EchoAgent
77
+ def on_connect(connection)
78
+ @connection = connection
79
+ end
80
+
81
+ def initialize_acp(request)
82
+ S::InitializeResponse.new(
83
+ protocol_version: request.protocol_version,
84
+ agent_info: S::Implementation.new(name: "echo-agent", version: "0.1")
85
+ )
86
+ end
87
+
88
+ def new_session(_request)
89
+ S::NewSessionResponse.new(session_id: SecureRandom.uuid)
90
+ end
91
+
92
+ def prompt(request)
93
+ text = request.prompt.map { |block| block.respond_to?(:text) ? block.text : "" }.join
94
+ @connection.session_update(
95
+ session_id: request.session_id,
96
+ update: S::AgentMessageChunk.new(content: S::TextContentBlock.new(text: text.reverse))
97
+ )
98
+ S::PromptResponse.new(stop_reason: "end_turn")
99
+ end
100
+
101
+ def cancel(_notification); end
102
+ end
103
+
104
+ ACP::Agent.run_agent(EchoAgent.new)
105
+ ```
106
+
107
+ `run_agent` starts the reactor with `Sync` and listens until stdin closes. The ACP `initialize` method maps to `initialize_acp` on the agent handler so it does not collide with Ruby's constructor. Every other method keeps its snake_case name (`new_session`, `load_session`, `prompt`, `cancel`, `set_session_mode`, ...).
108
+
109
+ ## Schema models
110
+
111
+ `ACP::Schema` contains a class for every definition in the ACP schema. Models accept snake_case keyword arguments, serialize to camelCase JSON with `to_h`/`to_json`, and parse with `from_hash`. Unions (`ContentBlock`, `SessionUpdate`, `ToolCallContent`, ...) resolve to the right variant automatically; enums and required fields are validated on construction and on parse.
112
+
113
+ ```ruby
114
+ block = ACP::Schema::ContentBlock.from_hash({ "type" => "text", "text" => "hi" })
115
+ block.class # => ACP::Schema::TextContentBlock
116
+ block.to_h # => { "type" => "text", "text" => "hi" }
117
+ ```
118
+
119
+ `ACP::PROTOCOL_VERSION`, `ACP::AGENT_METHODS`, `ACP::CLIENT_METHODS` and `ACP::PROTOCOL_METHODS` mirror the official `meta.json`.
120
+
121
+ ## Lower level
122
+
123
+ `ACP::Connection` is the transport-agnostic JSON-RPC layer (requests with optional timeouts, ordered notification delivery, observers for tracing traffic). `ACP::NdjsonTransport` wraps a pair of IO objects; `ACP::MemoryTransport.pair` gives two in-memory ends for tests.
124
+
125
+ Logging goes through `ACP.logger` (a `Logger`, `WARN` level by default); assign your own to integrate with the host application.
126
+
127
+ ## Updating the schema
128
+
129
+ `schema/schema.json`, `schema/meta.json` and `schema/VERSION` are vendored from the [ACP repository](https://github.com/agentclientprotocol/agent-client-protocol). After replacing them run:
130
+
131
+ ```bash
132
+ bundle exec rake gen_schema
133
+ bundle exec rake test
134
+ ```
135
+
136
+ `lib/acp/schema.rb` and `lib/acp/meta.rb` are generated files; do not edit them by hand.
137
+
138
+ ## Development
139
+
140
+ ```bash
141
+ bundle install
142
+ bundle exec rake test
143
+ ```
144
+
145
+ ## License
146
+
147
+ This project is dedicated to the public domain under the [Unlicense](https://unlicense.org). You may use, copy, modify, and distribute the code without restriction.
data/lib/acp/agent.rb ADDED
@@ -0,0 +1,201 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "connection"
4
+ require_relative "meta"
5
+ require_relative "router"
6
+ require_relative "schema"
7
+ require_relative "transport"
8
+ require_relative "wait"
9
+
10
+ module ACP
11
+ module Agent
12
+ class Connection
13
+ attr_reader :conn, :agent
14
+
15
+ def initialize(agent, transport, **connection_options)
16
+ @agent = agent
17
+ router = self.class.build_router(agent)
18
+ @conn = ACP::Connection.new(router.method(:call), transport, **connection_options)
19
+ agent.on_connect(self) if agent.respond_to?(:on_connect)
20
+ end
21
+
22
+ def self.build_router(agent)
23
+ router = Router.new
24
+
25
+ router.route_request(AGENT_METHODS["initialize"], Schema::InitializeRequest, agent, :initialize_acp)
26
+ router.route_request(AGENT_METHODS["authenticate"], Schema::AuthenticateRequest, agent, :authenticate,
27
+ normalize: true)
28
+ router.route_request(AGENT_METHODS["session_new"], Schema::NewSessionRequest, agent, :new_session)
29
+ router.route_request(AGENT_METHODS["session_load"], Schema::LoadSessionRequest, agent, :load_session,
30
+ normalize: true)
31
+ router.route_request(AGENT_METHODS["session_list"], Schema::ListSessionsRequest, agent, :list_sessions)
32
+ router.route_request(AGENT_METHODS["session_fork"], Schema::ForkSessionRequest, agent, :fork_session)
33
+ router.route_request(AGENT_METHODS["session_resume"], Schema::ResumeSessionRequest, agent, :resume_session)
34
+ router.route_request(AGENT_METHODS["session_close"], Schema::CloseSessionRequest, agent, :close_session,
35
+ normalize: true)
36
+ router.route_request(AGENT_METHODS["session_delete"], Schema::DeleteSessionRequest, agent, :delete_session,
37
+ normalize: true)
38
+ router.route_request(AGENT_METHODS["session_set_mode"], Schema::SetSessionModeRequest, agent,
39
+ :set_session_mode, normalize: true)
40
+ router.route_request(AGENT_METHODS["session_set_config_option"], Schema::SetSessionConfigOptionRequest, agent,
41
+ :set_session_config_option, :set_config_option, normalize: true)
42
+ router.route_request(AGENT_METHODS["session_prompt"], Schema::PromptRequest, agent, :prompt)
43
+ router.route_request(AGENT_METHODS["logout"], Schema::LogoutRequest, agent, :logout, normalize: true)
44
+ router.route_request(AGENT_METHODS["providers_list"], Schema::ListProvidersRequest, agent, :list_providers)
45
+ router.route_request(AGENT_METHODS["providers_set"], Schema::SetProviderRequest, agent, :set_provider,
46
+ normalize: true)
47
+ router.route_request(AGENT_METHODS["providers_disable"], Schema::DisableProviderRequest, agent,
48
+ :disable_provider, normalize: true)
49
+ router.route_request(AGENT_METHODS["mcp_message"], Schema::MessageMcpRequest, agent, :mcp_message)
50
+
51
+ router.route_notification(AGENT_METHODS["session_cancel"], Schema::CancelNotification, agent,
52
+ :cancel, :cancel_session)
53
+
54
+ router.on_extension_request do |name, params|
55
+ raise RequestError.method_not_found("_#{name}") unless agent.respond_to?(:ext_method)
56
+
57
+ agent.ext_method(name, params)
58
+ end
59
+
60
+ router.on_extension_notification do |name, params|
61
+ agent.ext_notification(name, params) if agent.respond_to?(:ext_notification)
62
+ end
63
+
64
+ router
65
+ end
66
+
67
+ def listen
68
+ @conn.listen
69
+ end
70
+
71
+ def start
72
+ @conn.start
73
+ end
74
+
75
+ def join(timeout = nil)
76
+ @conn.join(timeout)
77
+ end
78
+
79
+ def add_observer(callable = nil, &block)
80
+ @conn.add_observer(callable, &block)
81
+ end
82
+
83
+ def close
84
+ @conn.close
85
+ end
86
+
87
+ def closed?
88
+ @conn.closed?
89
+ end
90
+
91
+ def session_update(session_id:, update:, **meta)
92
+ notify(CLIENT_METHODS["session_update"], Schema::SessionNotification, session_id: session_id, update: update, **meta)
93
+ end
94
+
95
+ def request_permission(session_id:, tool_call:, options:, **meta)
96
+ request(
97
+ CLIENT_METHODS["session_request_permission"], Schema::RequestPermissionRequest,
98
+ Schema::RequestPermissionResponse,
99
+ session_id: session_id, tool_call: tool_call, options: options, **meta
100
+ )
101
+ end
102
+
103
+ def read_text_file(session_id:, path:, line: nil, limit: nil, **meta)
104
+ request(
105
+ CLIENT_METHODS["fs_read_text_file"], Schema::ReadTextFileRequest, Schema::ReadTextFileResponse,
106
+ session_id: session_id, path: path, line: line, limit: limit, **meta
107
+ )
108
+ end
109
+
110
+ def write_text_file(session_id:, path:, content:, **meta)
111
+ request(
112
+ CLIENT_METHODS["fs_write_text_file"], Schema::WriteTextFileRequest, Schema::WriteTextFileResponse,
113
+ session_id: session_id, path: path, content: content, **meta
114
+ )
115
+ end
116
+
117
+ def create_terminal(session_id:, command:, args: nil, cwd: nil, env: nil, output_byte_limit: nil, **meta)
118
+ request(
119
+ CLIENT_METHODS["terminal_create"], Schema::CreateTerminalRequest, Schema::CreateTerminalResponse,
120
+ session_id: session_id, command: command, args: args, cwd: cwd, env: env,
121
+ output_byte_limit: output_byte_limit, **meta
122
+ )
123
+ end
124
+
125
+ def terminal_output(session_id:, terminal_id:, **meta)
126
+ request(
127
+ CLIENT_METHODS["terminal_output"], Schema::TerminalOutputRequest, Schema::TerminalOutputResponse,
128
+ session_id: session_id, terminal_id: terminal_id, **meta
129
+ )
130
+ end
131
+
132
+ def release_terminal(session_id:, terminal_id:, **meta)
133
+ request(
134
+ CLIENT_METHODS["terminal_release"], Schema::ReleaseTerminalRequest, Schema::ReleaseTerminalResponse,
135
+ session_id: session_id, terminal_id: terminal_id, **meta
136
+ )
137
+ end
138
+
139
+ def wait_for_terminal_exit(session_id:, terminal_id:, **meta)
140
+ request(
141
+ CLIENT_METHODS["terminal_wait_for_exit"], Schema::WaitForTerminalExitRequest,
142
+ Schema::WaitForTerminalExitResponse,
143
+ session_id: session_id, terminal_id: terminal_id, **meta
144
+ )
145
+ end
146
+
147
+ def kill_terminal(session_id:, terminal_id:, **meta)
148
+ request(
149
+ CLIENT_METHODS["terminal_kill"], Schema::KillTerminalRequest, Schema::KillTerminalResponse,
150
+ session_id: session_id, terminal_id: terminal_id, **meta
151
+ )
152
+ end
153
+
154
+ def create_elicitation(request)
155
+ payload = Schema::CreateElicitationRequest.coerce(request)
156
+ result = @conn.send_request(CLIENT_METHODS["elicitation_create"], payload)
157
+ Schema::CreateElicitationResponse.coerce(result || {})
158
+ end
159
+
160
+ def complete_elicitation(elicitation_id:, **meta)
161
+ notify(CLIENT_METHODS["elicitation_complete"], Schema::CompleteElicitationNotification,
162
+ elicitation_id: elicitation_id, **meta)
163
+ end
164
+
165
+ def ext_method(name, params = {})
166
+ @conn.send_request("_#{name}", params)
167
+ end
168
+
169
+ def ext_notification(name, params = {})
170
+ @conn.send_notification("_#{name}", params)
171
+ end
172
+
173
+ private
174
+
175
+ def request(method, request_class, response_class, **kwargs)
176
+ payload = build(request_class, kwargs)
177
+ result = @conn.send_request(method, payload)
178
+ response_class.coerce(result || {})
179
+ end
180
+
181
+ def notify(method, request_class, **kwargs)
182
+ @conn.send_notification(method, build(request_class, kwargs))
183
+ end
184
+
185
+ def build(request_class, kwargs)
186
+ meta = kwargs.delete(:field_meta) || kwargs.delete(:meta)
187
+ model = request_class.new(**kwargs.compact)
188
+ model.field_meta = meta if meta
189
+ model
190
+ end
191
+ end
192
+
193
+ def self.run_agent(agent, input: $stdin, output: $stdout, **connection_options)
194
+ input, output = Stdio.stdio_streams(input, output)
195
+ transport = NdjsonTransport.new(input, output)
196
+ connection = Connection.new(agent, transport, **connection_options)
197
+ Sync { connection.listen }
198
+ connection
199
+ end
200
+ end
201
+ end
data/lib/acp/client.rb ADDED
@@ -0,0 +1,213 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "connection"
4
+ require_relative "meta"
5
+ require_relative "router"
6
+ require_relative "schema"
7
+
8
+ module ACP
9
+ module Client
10
+ class Connection
11
+ DEFAULT_DRAIN_TIMEOUT = 1.0
12
+
13
+ attr_reader :conn, :handler
14
+
15
+ def initialize(handler, transport, drain_timeout: DEFAULT_DRAIN_TIMEOUT, **connection_options)
16
+ @handler = handler
17
+ @drain_timeout = drain_timeout
18
+ router = self.class.build_router(handler)
19
+ @conn = ACP::Connection.new(router.method(:call), transport, **connection_options)
20
+ handler.on_connect(self) if handler.respond_to?(:on_connect)
21
+ end
22
+
23
+ def self.build_router(client)
24
+ router = Router.new
25
+
26
+ router.route_request(CLIENT_METHODS["fs_write_text_file"], Schema::WriteTextFileRequest, client,
27
+ :write_text_file, normalize: true)
28
+ router.route_request(CLIENT_METHODS["fs_read_text_file"], Schema::ReadTextFileRequest, client,
29
+ :read_text_file)
30
+ router.route_request(CLIENT_METHODS["session_request_permission"], Schema::RequestPermissionRequest, client,
31
+ :request_permission)
32
+ router.route_request(CLIENT_METHODS["terminal_create"], Schema::CreateTerminalRequest, client,
33
+ :create_terminal, optional: true)
34
+ router.route_request(CLIENT_METHODS["terminal_output"], Schema::TerminalOutputRequest, client,
35
+ :terminal_output, optional: true)
36
+ router.route_request(CLIENT_METHODS["terminal_release"], Schema::ReleaseTerminalRequest, client,
37
+ :release_terminal, optional: true, default_result: {}, normalize: true)
38
+ router.route_request(CLIENT_METHODS["terminal_wait_for_exit"], Schema::WaitForTerminalExitRequest, client,
39
+ :wait_for_terminal_exit, optional: true)
40
+ router.route_request(CLIENT_METHODS["terminal_kill"], Schema::KillTerminalRequest, client,
41
+ :kill_terminal, optional: true, default_result: {}, normalize: true)
42
+ router.route_request(CLIENT_METHODS["elicitation_create"], Schema::CreateElicitationRequest, client,
43
+ :create_elicitation, normalize: true)
44
+ router.route_request(CLIENT_METHODS["mcp_connect"], Schema::ConnectMcpRequest, client, :mcp_connect)
45
+ router.route_request(CLIENT_METHODS["mcp_message"], Schema::MessageMcpRequest, client, :mcp_message)
46
+ router.route_request(CLIENT_METHODS["mcp_disconnect"], Schema::DisconnectMcpRequest, client,
47
+ :mcp_disconnect, normalize: true)
48
+
49
+ router.route_notification(CLIENT_METHODS["session_update"], Schema::SessionNotification, client,
50
+ :session_update)
51
+ router.route_notification(CLIENT_METHODS["elicitation_complete"], Schema::CompleteElicitationNotification,
52
+ client, :complete_elicitation)
53
+
54
+ router.on_extension_request do |name, params|
55
+ raise RequestError.method_not_found("_#{name}") unless client.respond_to?(:ext_method)
56
+
57
+ client.ext_method(name, params)
58
+ end
59
+
60
+ router.on_extension_notification do |name, params|
61
+ client.ext_notification(name, params) if client.respond_to?(:ext_notification)
62
+ end
63
+
64
+ router
65
+ end
66
+
67
+ def listen
68
+ @conn.listen
69
+ end
70
+
71
+ def start
72
+ @conn.start
73
+ end
74
+
75
+ def join(timeout = nil)
76
+ @conn.join(timeout)
77
+ end
78
+
79
+ def add_observer(callable = nil, &block)
80
+ @conn.add_observer(callable, &block)
81
+ end
82
+
83
+ def close
84
+ @conn.close
85
+ end
86
+
87
+ def closed?
88
+ @conn.closed?
89
+ end
90
+
91
+ def initialize_agent(protocol_version: PROTOCOL_VERSION, client_capabilities: nil, client_info: nil, **meta)
92
+ request(
93
+ AGENT_METHODS["initialize"], Schema::InitializeRequest, Schema::InitializeResponse,
94
+ protocol_version: protocol_version, client_capabilities: client_capabilities, client_info: client_info, **meta
95
+ )
96
+ end
97
+
98
+ def authenticate(method_id:, **meta)
99
+ request(AGENT_METHODS["authenticate"], Schema::AuthenticateRequest, Schema::AuthenticateResponse,
100
+ method_id: method_id, **meta)
101
+ end
102
+
103
+ def new_session(cwd:, mcp_servers: [], additional_directories: nil, **meta)
104
+ request(
105
+ AGENT_METHODS["session_new"], Schema::NewSessionRequest, Schema::NewSessionResponse,
106
+ cwd: cwd, mcp_servers: mcp_servers || [], additional_directories: additional_directories, **meta
107
+ )
108
+ end
109
+
110
+ def load_session(cwd:, session_id:, mcp_servers: [], additional_directories: nil, **meta)
111
+ request(
112
+ AGENT_METHODS["session_load"], Schema::LoadSessionRequest, Schema::LoadSessionResponse,
113
+ cwd: cwd, session_id: session_id, mcp_servers: mcp_servers || [],
114
+ additional_directories: additional_directories, **meta
115
+ )
116
+ end
117
+
118
+ def list_sessions(cwd: nil, cursor: nil, **meta)
119
+ request(AGENT_METHODS["session_list"], Schema::ListSessionsRequest, Schema::ListSessionsResponse,
120
+ cwd: cwd, cursor: cursor, **meta)
121
+ end
122
+
123
+ def fork_session(session_id:, cwd:, mcp_servers: nil, additional_directories: nil, **meta)
124
+ request(
125
+ AGENT_METHODS["session_fork"], Schema::ForkSessionRequest, Schema::ForkSessionResponse,
126
+ session_id: session_id, cwd: cwd, mcp_servers: mcp_servers,
127
+ additional_directories: additional_directories, **meta
128
+ )
129
+ end
130
+
131
+ def resume_session(session_id:, cwd:, mcp_servers: nil, additional_directories: nil, **meta)
132
+ request(
133
+ AGENT_METHODS["session_resume"], Schema::ResumeSessionRequest, Schema::ResumeSessionResponse,
134
+ session_id: session_id, cwd: cwd, mcp_servers: mcp_servers,
135
+ additional_directories: additional_directories, **meta
136
+ )
137
+ end
138
+
139
+ def close_session(session_id:, **meta)
140
+ request(AGENT_METHODS["session_close"], Schema::CloseSessionRequest, Schema::CloseSessionResponse,
141
+ session_id: session_id, **meta)
142
+ end
143
+
144
+ def delete_session(session_id:, **meta)
145
+ request(AGENT_METHODS["session_delete"], Schema::DeleteSessionRequest, Schema::DeleteSessionResponse,
146
+ session_id: session_id, **meta)
147
+ end
148
+
149
+ def set_session_mode(session_id:, mode_id:, **meta)
150
+ request(AGENT_METHODS["session_set_mode"], Schema::SetSessionModeRequest, Schema::SetSessionModeResponse,
151
+ session_id: session_id, mode_id: mode_id, **meta)
152
+ end
153
+
154
+ def set_session_config_option(session_id:, config_id:, value:, **meta)
155
+ request_class = if value == true || value == false
156
+ Schema::SetSessionConfigOptionBooleanRequest
157
+ else
158
+ Schema::SetSessionConfigOptionSelectRequest
159
+ end
160
+ request(
161
+ AGENT_METHODS["session_set_config_option"], request_class, Schema::SetSessionConfigOptionResponse,
162
+ session_id: session_id, config_id: config_id, value: value, **meta
163
+ )
164
+ end
165
+
166
+ def prompt(session_id:, prompt:, **meta)
167
+ request(AGENT_METHODS["session_prompt"], Schema::PromptRequest, Schema::PromptResponse,
168
+ session_id: session_id, prompt: prompt, **meta)
169
+ ensure
170
+ drain_notifications
171
+ end
172
+
173
+ def cancel(session_id:, **meta)
174
+ notify(AGENT_METHODS["session_cancel"], Schema::CancelNotification, session_id: session_id, **meta)
175
+ end
176
+
177
+ def logout(**meta)
178
+ request(AGENT_METHODS["logout"], Schema::LogoutRequest, Schema::LogoutResponse, **meta)
179
+ end
180
+
181
+ def ext_method(name, params = {})
182
+ @conn.send_request("_#{name}", params)
183
+ end
184
+
185
+ def ext_notification(name, params = {})
186
+ @conn.send_notification("_#{name}", params)
187
+ end
188
+
189
+ def drain_notifications(timeout: @drain_timeout)
190
+ @conn.drain_notifications(timeout: timeout)
191
+ end
192
+
193
+ private
194
+
195
+ def request(method, request_class, response_class, **kwargs)
196
+ payload = build(request_class, kwargs)
197
+ result = @conn.send_request(method, payload)
198
+ response_class.coerce(result || {})
199
+ end
200
+
201
+ def notify(method, request_class, **kwargs)
202
+ @conn.send_notification(method, build(request_class, kwargs))
203
+ end
204
+
205
+ def build(request_class, kwargs)
206
+ meta = kwargs.delete(:field_meta) || kwargs.delete(:meta)
207
+ model = request_class.new(**kwargs.compact)
208
+ model.field_meta = meta if meta
209
+ model
210
+ end
211
+ end
212
+ end
213
+ end