ask-session-protocol 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.
@@ -0,0 +1,260 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "timeout"
5
+ require "thread"
6
+
7
+ module Ask
8
+ module SessionProtocol
9
+ # A thin client of the canonical Ask::SessionProtocol. Speaks NDJSON
10
+ # over an input/output pair (the spawned host's stdio, or a unix
11
+ # socket) and knows nothing else: no runtime, no sessions — the host
12
+ # owns all of it.
13
+ #
14
+ # A reader thread routes incoming messages: responses are matched to
15
+ # their pending request by id and delivered with a ConditionVariable;
16
+ # everything else (session/event notifications, reverse requests) is
17
+ # pushed to a notification queue for the caller to drain.
18
+ class Client
19
+ # Raised when a request gets no response in time.
20
+ class RequestTimeout < StandardError; end
21
+
22
+ # Raised when the host closes the connection.
23
+ class ConnectionClosed < StandardError; end
24
+
25
+ # A JSON-RPC error from the host.
26
+ class ProtocolError < StandardError
27
+ attr_reader :code
28
+
29
+ def initialize(code, message)
30
+ @code = code
31
+ super(message)
32
+ end
33
+ end
34
+
35
+ # Sentinel notification pushed when the host closes the connection,
36
+ # so waiters on {#wait_notification} unblock.
37
+ CONNECTION_CLOSED = { "method" => "connection.closed" }.freeze
38
+
39
+ # @param input [IO] NDJSON source (host stdout / socket)
40
+ # @param output [IO] NDJSON sink (host stdin / socket)
41
+ def initialize(input:, output:)
42
+ @input = input
43
+ @output = output
44
+ @pending = {}
45
+ @next_id = 1
46
+ @mutex = Mutex.new
47
+ @condition = ConditionVariable.new
48
+ @notifications = Queue.new
49
+ @closed = false
50
+ @reader = Thread.new { read_loop }
51
+ end
52
+
53
+ # Send a request and wait for its response.
54
+ #
55
+ # @param method [String] canonical client → host method
56
+ # @param params [Hash]
57
+ # @param timeout [Numeric, nil] seconds to wait; nil waits forever
58
+ # @return [Hash] the response result
59
+ # @raise [ProtocolError] on a JSON-RPC error response
60
+ # @raise [RequestTimeout] when no response arrives in time
61
+ # @raise [ConnectionClosed] when the host closed the connection
62
+ def request(method, params = {}, timeout: nil)
63
+ raise ConnectionClosed, "connection is closed" if @closed
64
+
65
+ id = next_id
66
+ entry = { done: false, result: nil, error: nil }
67
+ @mutex.synchronize { @pending[id] = entry }
68
+
69
+ begin
70
+ @output.puts(JSON.generate({ id: id, method: method, params: params }))
71
+ @output.flush
72
+ rescue Errno::EPIPE, IOError
73
+ raise ConnectionClosed, "host closed the connection"
74
+ end
75
+
76
+ deadline = timeout && (Time.now + timeout)
77
+ @mutex.synchronize do
78
+ until entry[:done]
79
+ remaining = deadline && (deadline - Time.now)
80
+ raise RequestTimeout, "#{method} timed out after #{timeout}s" if remaining && remaining <= 0
81
+
82
+ @condition.wait(@mutex, remaining || 1)
83
+ end
84
+ end
85
+ raise entry[:error] if entry[:error]
86
+
87
+ entry[:result]
88
+ ensure
89
+ @mutex.synchronize { @pending.delete(id) } if id
90
+ end
91
+
92
+ # Wait for the next notification (blocking, or with a timeout).
93
+ # Returns a Hash { "method" =>, "params" => }, or the
94
+ # {CONNECTION_CLOSED} sentinel when the host disconnected.
95
+ #
96
+ # The sentinel is delivered to every waiter: several watchers may
97
+ # share one connection (the board watches every task of a project
98
+ # over a single client), and a single queued sentinel would wake
99
+ # only one of them — the rest would wait on an empty queue
100
+ # forever, keeping their runs' leases fresh so recovery never
101
+ # touches them.
102
+ def wait_notification(timeout: nil)
103
+ if @closed && @notifications.empty?
104
+ return CONNECTION_CLOSED
105
+ end
106
+
107
+ return @notifications.pop unless timeout
108
+
109
+ Timeout.timeout(timeout) { @notifications.pop }
110
+ rescue Timeout::Error
111
+ nil
112
+ end
113
+
114
+ # True when the host connection has closed.
115
+ def closed?
116
+ @closed
117
+ end
118
+
119
+ # Close the client: close the sink (which ends the host's stdin,
120
+ # for spawned hosts) and stop the reader thread.
121
+ def close
122
+ @output.close rescue nil
123
+ @reader&.kill rescue nil
124
+ end
125
+
126
+ # ── Convenience wrappers over the canonical surface ────────────────
127
+
128
+ def initialize!(client: { name: "ask-session-protocol", version: Ask::SessionProtocol::VERSION })
129
+ request("initialize", { client: client })
130
+ end
131
+
132
+ def create_session(workspace_path: nil, mode: nil, model: nil, tools: nil, system_prompt: nil)
133
+ params = {
134
+ workspace: { workspacePath: workspace_path },
135
+ mode: mode,
136
+ model: model
137
+ }.compact
138
+ params[:tools] = tools if tools
139
+ params[:systemPrompt] = system_prompt if system_prompt
140
+ request("session/create", params)
141
+ end
142
+
143
+ def subscribe(session_id, after_seq: 0)
144
+ request("session/subscribe", { sessionId: session_id, afterSeq: after_seq })
145
+ end
146
+
147
+ def send(session_id, content, expected_turn_id: nil)
148
+ params = { sessionId: session_id, content: content }
149
+ params[:expectedTurnId] = expected_turn_id if expected_turn_id
150
+ request("session/send", params)
151
+ end
152
+
153
+ def abort(session_id)
154
+ request("session/abort", { sessionId: session_id })
155
+ end
156
+
157
+ def close_session(session_id)
158
+ request("session/close", { sessionId: session_id })
159
+ end
160
+
161
+ def list_interactions(session_id)
162
+ request("interaction/list", { sessionId: session_id })
163
+ end
164
+
165
+ def approve(session_id, interaction_id)
166
+ request("interaction/approve", { sessionId: session_id, interactionId: interaction_id })
167
+ end
168
+
169
+ def reject(session_id, interaction_id)
170
+ request("interaction/reject", { sessionId: session_id, interactionId: interaction_id })
171
+ end
172
+
173
+ def approve_all(session_id)
174
+ request("interaction/approve-all", { sessionId: session_id })
175
+ end
176
+
177
+ def reject_all(session_id)
178
+ request("interaction/reject-all", { sessionId: session_id })
179
+ end
180
+
181
+ def plan_approve(session_id)
182
+ request("plan/approve", { sessionId: session_id })
183
+ end
184
+
185
+ def plan_reject(session_id)
186
+ request("plan/reject", { sessionId: session_id })
187
+ end
188
+
189
+ def read_workspace_state(session_id = nil)
190
+ params = session_id ? { sessionId: session_id } : {}
191
+ request("workspace/readState", params)
192
+ end
193
+
194
+ private
195
+
196
+ def next_id
197
+ @mutex.synchronize do
198
+ id = @next_id
199
+ @next_id += 1
200
+ id
201
+ end
202
+ end
203
+
204
+ def read_loop
205
+ while (line = @input.gets)
206
+ line = line.strip
207
+ next if line.empty?
208
+
209
+ begin
210
+ msg = JSON.parse(line)
211
+ rescue JSON::ParserError
212
+ # Non-protocol noise on the stream (a stray log line); ignore
213
+ # it — only EOF or I/O errors mean the host is gone.
214
+ next
215
+ end
216
+ if response?(msg)
217
+ deliver_response(msg)
218
+ else
219
+ @notifications << msg
220
+ end
221
+ end
222
+ rescue IOError, SystemCallError
223
+ # Host went away; fall through to close handling.
224
+ ensure
225
+ mark_closed
226
+ end
227
+
228
+ def response?(msg)
229
+ msg["id"] && !msg.key?("method")
230
+ end
231
+
232
+ def deliver_response(msg)
233
+ entry = @mutex.synchronize { @pending.delete(msg["id"]) }
234
+ return unless entry
235
+
236
+ entry[:error] = build_error(msg["error"]) if msg["error"]
237
+ entry[:result] = msg["result"] unless msg["error"]
238
+ entry[:done] = true
239
+ @mutex.synchronize { @condition.broadcast }
240
+ end
241
+
242
+ def build_error(error)
243
+ ProtocolError.new(error["code"], error["message"].to_s)
244
+ end
245
+
246
+ def mark_closed
247
+ @mutex.synchronize do
248
+ @closed = true
249
+ @pending.each_value do |entry|
250
+ entry[:error] = ConnectionClosed.new("host closed the connection")
251
+ entry[:done] = true
252
+ end
253
+ @pending.clear
254
+ @condition.broadcast
255
+ end
256
+ @notifications << CONNECTION_CLOSED
257
+ end
258
+ end
259
+ end
260
+ end
@@ -0,0 +1,322 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ask
4
+ module SessionProtocol
5
+ # The canonical session event vocabulary.
6
+ #
7
+ # Every event on the wire has the same envelope:
8
+ #
9
+ # { "type" => "model.streaming", "seq" => 12, "payload" => { "delta" => "..." } }
10
+ #
11
+ # * type — one of the canonical dot-names below (additive growth only;
12
+ # clients must tolerate unknown types within a major version)
13
+ # * seq — monotonically increasing per session; clients use it for
14
+ # ordering, dedup, and replay (ask "events after seq N")
15
+ # * payload — the event body, string-keyed
16
+ #
17
+ # Events marked `interaction` (approval.required, plan.proposed) are
18
+ # resolvable by id from ANY client through the interaction/* and
19
+ # plan/* methods — a terminal, web console, or bot resolves the same
20
+ # pending interaction, and the host tombstones delivery per subscriber.
21
+ module Events
22
+ # Payload field types accepted by the validator.
23
+ FIELD_TYPES = %i[string integer number boolean object array any].freeze
24
+
25
+ # The canonical event registry. Each entry describes the event and the
26
+ # shape of its payload. This registry is the single source of truth
27
+ # for the event vocabulary — the JSON Schema artifact is generated
28
+ # from it, and hosts/clients validate against it.
29
+ TYPES = {
30
+ # ── Session lifecycle ────────────────────────────────────────────
31
+ "session.created" => {
32
+ description: "A session was created and is ready to receive prompts.",
33
+ interaction: false,
34
+ payload: {
35
+ "sessionId" => { type: :string, required: true, description: "The session identifier." }
36
+ }
37
+ },
38
+ "session.ended" => {
39
+ description: "The session was closed and its resources released.",
40
+ interaction: false,
41
+ payload: {
42
+ "sessionId" => { type: :string, required: true, description: "The session identifier." },
43
+ "reason" => { type: :string, required: false, description: "Why the session ended: closed, aborted, error, ..." }
44
+ }
45
+ },
46
+
47
+ # ── Turn lifecycle ───────────────────────────────────────────────
48
+ "turn.started" => {
49
+ description: "A turn (prompt → response → tool loop) began.",
50
+ interaction: false,
51
+ payload: {
52
+ "turnId" => { type: :string, required: true, description: "Identifies the turn; correlates all of its events." }
53
+ }
54
+ },
55
+ "turn.completed" => {
56
+ description: "A turn finished successfully. The accumulated response text is in `response`.",
57
+ interaction: false,
58
+ payload: {
59
+ "turnId" => { type: :string, required: true, description: "The turn identifier." },
60
+ "response" => { type: :string, required: false, description: "Final assistant text for the turn." },
61
+ "inputTokens" => { type: :integer, required: false, description: "LLM input tokens used in the turn." },
62
+ "outputTokens" => { type: :integer, required: false, description: "LLM output tokens used in the turn." },
63
+ "cost" => { type: :number, required: false, description: "Estimated cost of the turn in USD." }
64
+ }
65
+ },
66
+ "turn.failed" => {
67
+ description: "A turn failed; the session remains usable.",
68
+ interaction: false,
69
+ payload: {
70
+ "turnId" => { type: :string, required: true, description: "The turn identifier." },
71
+ "error" => { type: :string, required: true, description: "Human-readable failure reason." }
72
+ }
73
+ },
74
+ "turn.aborted" => {
75
+ description: "A turn was aborted by the user (session/abort) or the host.",
76
+ interaction: false,
77
+ payload: {
78
+ "turnId" => { type: :string, required: true, description: "The turn identifier." }
79
+ }
80
+ },
81
+
82
+ # ── Model output ─────────────────────────────────────────────────
83
+ "model.streaming" => {
84
+ description: "A delta of the assistant's response text.",
85
+ interaction: false,
86
+ payload: {
87
+ "delta" => { type: :string, required: true, description: "The next chunk of text." }
88
+ }
89
+ },
90
+ "model.thinking" => {
91
+ description: "A delta of the model's reasoning/thinking text (rendered collapsibly).",
92
+ interaction: false,
93
+ payload: {
94
+ "delta" => { type: :string, required: true, description: "The next chunk of thinking text." }
95
+ }
96
+ },
97
+
98
+ # ── Tool execution ───────────────────────────────────────────────
99
+ "tool.use" => {
100
+ description: "The model requested a tool call; execution is about to start.",
101
+ interaction: false,
102
+ payload: {
103
+ "id" => { type: :string, required: true, description: "Tool call identifier; correlates use/delta/result." },
104
+ "name" => { type: :string, required: true, description: "Tool name, e.g. bash, write." },
105
+ "args" => { type: :any, required: false, description: "Tool arguments (object or pre-serialized string)." }
106
+ }
107
+ },
108
+ "tool.delta" => {
109
+ description: "A partial result while a tool is executing.",
110
+ interaction: false,
111
+ payload: {
112
+ "id" => { type: :string, required: true, description: "Tool call identifier." },
113
+ "name" => { type: :string, required: true, description: "Tool name." },
114
+ "partial" => { type: :string, required: true, description: "The next chunk of tool output." }
115
+ }
116
+ },
117
+ "tool.result" => {
118
+ description: "A tool finished executing.",
119
+ interaction: false,
120
+ payload: {
121
+ "id" => { type: :string, required: true, description: "Tool call identifier." },
122
+ "name" => { type: :string, required: true, description: "Tool name." },
123
+ "output" => { type: :any, required: false, description: "Tool output (string or structured value)." },
124
+ "isError" => { type: :boolean, required: false, description: "True when the tool failed." },
125
+ "durationMs" => { type: :integer, required: false, description: "Execution duration in milliseconds." }
126
+ }
127
+ },
128
+
129
+ # ── Interactions (resolvable by id from any client) ─────────────
130
+ "approval.required" => {
131
+ description: "A tool is queued for human approval. Resolve via interaction/approve or interaction/reject.",
132
+ interaction: true,
133
+ payload: {
134
+ "id" => { type: :string, required: true, description: "Interaction id; used to resolve this request." },
135
+ "toolName" => { type: :string, required: true, description: "The tool waiting for approval." },
136
+ "args" => { type: :any, required: false, description: "Tool arguments under review." },
137
+ "message" => { type: :string, required: false, description: "Reason the approval was requested." },
138
+ "autoApprovable" => { type: :boolean, required: false, description: "True when a configured rule may auto-approve." }
139
+ }
140
+ },
141
+ "approval.updated" => {
142
+ description: "A pending approval was resolved.",
143
+ interaction: false,
144
+ payload: {
145
+ "id" => { type: :string, required: true, description: "The interaction id from approval.required." },
146
+ "status" => { type: :string, required: true, enum: %w[approved rejected], description: "How it was resolved." }
147
+ }
148
+ },
149
+ "plan.proposed" => {
150
+ description: "The agent proposed a plan (plan mode). Resolve via plan/approve or plan/reject.",
151
+ interaction: true,
152
+ payload: {
153
+ "id" => { type: :string, required: true, description: "Interaction id; used to resolve this proposal." },
154
+ "plan" => { type: :string, required: true, description: "The proposed plan text." }
155
+ }
156
+ },
157
+ "plan.approved" => {
158
+ description: "A proposed plan was approved and the agent may proceed.",
159
+ interaction: false,
160
+ payload: {
161
+ "id" => { type: :string, required: false, description: "The interaction id from plan.proposed, when known." },
162
+ "plan" => { type: :string, required: true, description: "The approved plan text." }
163
+ }
164
+ },
165
+ "plan.rejected" => {
166
+ description: "A proposed plan was rejected; the agent stays in plan mode.",
167
+ interaction: false,
168
+ payload: {
169
+ "id" => { type: :string, required: false, description: "The interaction id from plan.proposed, when known." },
170
+ "plan" => { type: :string, required: true, description: "The rejected plan text." }
171
+ }
172
+ },
173
+
174
+ # ── Session state ────────────────────────────────────────────────
175
+ "todos.updated" => {
176
+ description: "The session's todo list changed. Payload carries the full list.",
177
+ interaction: false,
178
+ payload: {
179
+ "todos" => { type: :array, required: true, description: "Full todo list: [{id, title, status}]." }
180
+ }
181
+ },
182
+ "file.changed" => {
183
+ description: "A file in the workspace changed (create/modify/delete).",
184
+ interaction: false,
185
+ payload: {
186
+ "path" => { type: :string, required: true, description: "Workspace-relative file path." },
187
+ "type" => { type: :string, required: true, enum: %w[created modified deleted], description: "Kind of change." },
188
+ "patch" => { type: :string, required: false, description: "Unified diff text for created/modified files." }
189
+ }
190
+ },
191
+
192
+ # ── Errors ───────────────────────────────────────────────────────
193
+ "error" => {
194
+ description: "A non-fatal error was emitted outside a turn (recoverable when flagged).",
195
+ interaction: false,
196
+ payload: {
197
+ "error" => { type: :string, required: true, description: "Error message." },
198
+ "recoverable" => { type: :boolean, required: false, description: "True when the host can continue." }
199
+ }
200
+ }
201
+ }.freeze
202
+
203
+ # ── Envelope ───────────────────────────────────────────────────────
204
+
205
+ # An event on the wire. Immutable; validate on construction.
206
+ Event = Data.define(:type, :seq, :payload) do
207
+ # @param type [String] canonical event type (see Events::TYPES)
208
+ # @param seq [Integer] monotonic per-session sequence number (> 0)
209
+ # @param payload [Hash] string-keyed event body
210
+ def initialize(type:, seq:, payload: {})
211
+ unless Events.known?(type)
212
+ raise ArgumentError, "unknown session event type: #{type.inspect}"
213
+ end
214
+ unless seq.is_a?(Integer) && seq.positive?
215
+ raise ArgumentError, "event seq must be a positive integer, got: #{seq.inspect}"
216
+ end
217
+ raise ArgumentError, "event payload must be a Hash, got: #{payload.class}" unless payload.is_a?(Hash)
218
+
219
+ super(type: type, seq: seq, payload: payload.freeze)
220
+ end
221
+
222
+ # Wire shape: { "type" => ..., "seq" => ..., "payload" => ... }
223
+ def to_h
224
+ { "type" => type, "seq" => seq, "payload" => payload }
225
+ end
226
+ end
227
+
228
+ # Build and validate an event.
229
+ #
230
+ # @param type [String] canonical event type
231
+ # @param seq [Integer] monotonic per-session sequence number
232
+ # @param payload [Hash] event body; validated against the registry
233
+ # @return [Event]
234
+ def self.event(type:, seq:, payload: {})
235
+ validate_payload!(type, payload)
236
+ Event.new(type: type, seq: seq, payload: payload)
237
+ end
238
+
239
+ # Rebuild an event from its wire shape, validating as it goes.
240
+ #
241
+ # @param hash [Hash] { "type" =>, "seq" =>, "payload" => }
242
+ # @return [Event]
243
+ def self.from_h(hash)
244
+ hash = hash.transform_keys(&:to_s)
245
+ raise ArgumentError, "event must be a Hash" unless hash.is_a?(Hash)
246
+ raise ArgumentError, "event missing type" if hash["type"].nil?
247
+ raise ArgumentError, "event missing seq" if hash["seq"].nil?
248
+
249
+ payload = hash["payload"] || {}
250
+ event(type: hash["type"], seq: hash["seq"], payload: payload)
251
+ end
252
+
253
+ # Whether `type` is a canonical event type.
254
+ def self.known?(type)
255
+ TYPES.key?(type)
256
+ end
257
+
258
+ # Whether an event type is a resolvable interaction.
259
+ def self.interaction?(type)
260
+ spec = TYPES[type]
261
+ spec && spec[:interaction]
262
+ end
263
+
264
+ # The canonical event types, in registry order.
265
+ def self.types
266
+ TYPES.keys
267
+ end
268
+
269
+ # Validate a payload against the registry spec for `type`.
270
+ # Raises ArgumentError on unknown types, missing required fields, or
271
+ # type/enum mismatches. Unknown extra fields are allowed (forward
272
+ # compatibility within a major protocol version).
273
+ #
274
+ # @param type [String] canonical event type
275
+ # @param payload [Hash] event body
276
+ # @return [true] when valid
277
+ def self.validate_payload!(type, payload)
278
+ spec = TYPES[type]
279
+ raise ArgumentError, "unknown session event type: #{type.inspect}" unless spec
280
+ raise ArgumentError, "payload for #{type} must be a Hash" unless payload.is_a?(Hash)
281
+
282
+ spec[:payload].each do |field, field_spec|
283
+ value = payload[field]
284
+ if field_spec[:required] && value.nil?
285
+ raise ArgumentError, "event #{type} missing required payload field #{field.inspect}"
286
+ end
287
+ next if value.nil?
288
+
289
+ validate_field!(type, field, field_spec, value)
290
+ end
291
+ true
292
+ end
293
+
294
+ # Shared field validator, used by the events, interactions, and
295
+ # methods registries. Public because sibling modules call it with an
296
+ # explicit receiver.
297
+ #
298
+ # @api private
299
+ def self.validate_field!(type, field, field_spec, value)
300
+ kind = field_spec[:type]
301
+ ok =
302
+ case kind
303
+ when :any then true
304
+ when :string then value.is_a?(String)
305
+ when :integer then value.is_a?(Integer)
306
+ when :number then value.is_a?(Numeric)
307
+ when :boolean then value == true || value == false
308
+ when :object then value.is_a?(Hash)
309
+ when :array then value.is_a?(Array)
310
+ end
311
+ unless ok
312
+ raise ArgumentError, "event #{type} field #{field.inspect} must be a #{kind}, got #{value.class}"
313
+ end
314
+
315
+ if field_spec[:enum] && !field_spec[:enum].include?(value)
316
+ raise ArgumentError,
317
+ "event #{type} field #{field.inspect} must be one of #{field_spec[:enum].inspect}, got #{value.inspect}"
318
+ end
319
+ end
320
+ end
321
+ end
322
+ end
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+
5
+ module Ask
6
+ module SessionProtocol
7
+ # How the terminal client reaches a session host.
8
+ #
9
+ # spawn — launch ask-app-server as a subprocess (stdio transport)
10
+ # with a unix socket for multi-client attach; the
11
+ # "embedded" mode: one host process owned by this client
12
+ # connect — attach to an already-running host over its unix socket
13
+ # (multi-client mode: web console, bots, and this terminal
14
+ # share the same live sessions)
15
+ module Host
16
+ # A spawned host subprocess.
17
+ SpawnResult = Data.define(:client, :stderr, :process, :socket_path) do
18
+ # Close the client and reap the subprocess.
19
+ def close
20
+ client.close
21
+ stderr.close rescue nil
22
+ process&.wait rescue nil
23
+ end
24
+ end
25
+
26
+ # Spawn ask-app-server as a subprocess and return a Client over its
27
+ # stdio. By default the host also exposes a unix socket (unique per
28
+ # spawn under ~/.ask-app-server/sockets/) so other clients — the web
29
+ # console, bots, a second terminal — can attach to the same live
30
+ # sessions; the path is available on the result.
31
+ #
32
+ # @param command [String, nil] host command (defaults to the
33
+ # ask-app-server gem's executable, falling back to PATH)
34
+ # @param args [Array<String>] extra host arguments
35
+ # @param socket [Boolean, String, nil] true (default) spawns with a
36
+ # generated unique socket; a String uses that exact path; false
37
+ # disables the socket entirely
38
+ # @param env [Hash{String => String}] extra environment for the host
39
+ # process (e.g. ASK_APP_SERVER_PRELOAD for executor tools)
40
+ # @return [SpawnResult]
41
+ def self.spawn(command: nil, args: [], socket: true, env: {})
42
+ require "open3"
43
+ command ||= begin
44
+ Gem.bin_path("ask-app-server", "ask-app-server")
45
+ rescue Gem::Exception
46
+ "ask-app-server"
47
+ end
48
+
49
+ socket_path = resolve_socket_path(socket)
50
+ spawn_args = args.dup
51
+ spawn_args += ["--socket", socket_path] if socket_path
52
+
53
+ stdin, stdout, stderr, wait_thread = Open3.popen3(env, command, *spawn_args)
54
+ client = Client.new(input: stdout, output: stdin)
55
+ SpawnResult.new(client: client, stderr: stderr, process: wait_thread, socket_path: socket_path)
56
+ end
57
+
58
+ # Attach to an existing host over its unix socket.
59
+ #
60
+ # @param socket_path [String] host socket (printed by `ask` on spawn)
61
+ # @return [Client]
62
+ def self.connect(socket_path)
63
+ require "socket"
64
+ socket = UNIXSocket.new(socket_path)
65
+ Client.new(input: socket, output: socket)
66
+ end
67
+
68
+ # @api private
69
+ def self.resolve_socket_path(socket)
70
+ return nil if socket == false || socket.nil?
71
+ return socket if socket.is_a?(String)
72
+
73
+ # Explicit env wins; otherwise a unique path per spawn so
74
+ # concurrent hosts never fight over the same socket.
75
+ ENV["ASK_APP_SERVER_SOCKET"] ||
76
+ File.join(Dir.home, ".ask-app-server", "sockets", "#{SecureRandom.hex(8)}.sock")
77
+ end
78
+ private_class_method :resolve_socket_path
79
+ end
80
+ end
81
+ end