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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +58 -0
- data/LICENSE +21 -0
- data/README.md +137 -0
- data/docs/ask-session-protocol.schema.json +1428 -0
- data/lib/ask/session_protocol/client.rb +260 -0
- data/lib/ask/session_protocol/events.rb +322 -0
- data/lib/ask/session_protocol/host.rb +81 -0
- data/lib/ask/session_protocol/interactions.rb +139 -0
- data/lib/ask/session_protocol/methods.rb +393 -0
- data/lib/ask/session_protocol/schema.rb +117 -0
- data/lib/ask/session_protocol/version.rb +7 -0
- data/lib/ask/session_protocol.rb +37 -0
- data/lib/ask-session-protocol.rb +3 -0
- metadata +127 -0
|
@@ -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
|