ask-app-server 0.1.2 → 0.4.2

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.
@@ -5,26 +5,47 @@ require "time"
5
5
 
6
6
  module Ask
7
7
  module AppServer
8
- # JSON-RPC server implementing the ZCode/Codex app-server protocol over stdio.
8
+ # JSON-RPC protocol engine implementing the canonical
9
+ # Ask::SessionProtocol. The session host: owns agent sessions behind one
10
+ # versioned contract that every client (terminal TUI, web console, bots,
11
+ # IDE) speaks.
9
12
  #
10
- # Communicates via NDJSON (Newline-Delimited JSON) over stdin/stdout.
11
- # Supports session management, streaming events, and mid-execution injection.
13
+ # The engine is transport-agnostic: messages arrive per-connection
14
+ # ({#dispatch}) and responses/notifications are written back to the
15
+ # requesting connection. Event delivery is cursor-based — each
16
+ # connection tracks the last delivered seq per subscribed session
17
+ # ({Connection}), so any number of clients can attach to the same
18
+ # sessions. Two transports use the engine:
12
19
  #
13
- # Protocol methods:
14
- # session/create, session/list, session/resume, session/subscribe,
15
- # session/send, session/events, session/abort, workspace/readState
20
+ # Server#start — stdio mode (one Connection over stdin/stdout)
21
+ # SocketServer — unix socket mode (a Connection per client)
16
22
  #
17
- # Notifications (server → client):
18
- # session/event, interaction/requestPermission, interaction/requestUserInput
23
+ # Client → host methods (see Ask::SessionProtocol::Methods):
24
+ # ping, initialize, session/create, session/list, session/resume,
25
+ # session/subscribe, session/events, session/send, session/abort,
26
+ # session/close, session/artifacts, session/artifact/get,
27
+ # interaction/list, interaction/approve, interaction/reject,
28
+ # interaction/approve-all, interaction/reject-all,
29
+ # interaction/respond, plan/approve, plan/reject, workspace/readState
19
30
  #
20
- # The server also handles incoming responses to its outgoing requests
21
- # (e.g., client responses to interaction/requestPermission).
31
+ # Host client notifications:
32
+ # session/event carries a canonical event envelope {type, seq, payload}
33
+ #
34
+ # The engine also handles incoming responses to its outgoing reverse
35
+ # requests (interaction/requestPermission, interaction/requestUserInput
36
+ # — the app-server interop surface).
22
37
  class Server
38
+ # Host capabilities advertised in `initialize`. The host emits every
39
+ # canonical event except file.changed today.
40
+ HOST_CAPABILITIES =
41
+ (Ask::SessionProtocol::Methods::CAPABILITIES - %w[fileEvents]).freeze
42
+
23
43
  def initialize(session_manager: nil)
24
44
  @session_manager = session_manager || SessionManager.new
25
45
  @running = false
26
46
  @started_at = nil
27
- @input_queue = Queue.new
47
+ @connections = []
48
+ @connections_mutex = Mutex.new
28
49
  @response_handlers = {} # outgoing request_id => Proc
29
50
  @outgoing_id = 0
30
51
  @logger = Logger.new($stdout, level: ENV["DEBUG"] ? Logger::DEBUG : Logger::WARN)
@@ -34,59 +55,51 @@ module Ask
34
55
  register_default_handlers
35
56
  end
36
57
 
37
- # Start the server — reads from stdin in a background thread.
38
- # The main thread handles event pushing and shutdown.
58
+ attr_reader :session_manager
59
+
60
+ # ── Connection registry ────────────────────────────────────────────
61
+
62
+ # All live connections (snapshot).
63
+ def connections
64
+ @connections_mutex.synchronize { @connections.dup }
65
+ end
66
+
67
+ # Register a connection for dispatch and event delivery.
68
+ def add_connection(connection)
69
+ @connections_mutex.synchronize { @connections << connection }
70
+ connection
71
+ end
72
+
73
+ # Deregister a connection (client disconnected).
74
+ def remove_connection(connection)
75
+ @connections_mutex.synchronize { @connections.delete(connection) }
76
+ connection.close
77
+ end
78
+
79
+ # ── Transports ─────────────────────────────────────────────────────
80
+
81
+ # Start the stdio transport: one Connection over stdin/stdout, a
82
+ # reader thread dispatching messages, and a pusher delivering events.
83
+ # Blocks until the input closes or {#stop} is called.
39
84
  def start
40
85
  @running = true
41
86
  @started_at = Time.now
87
+ connection = add_connection(Connection.new($stdin, $stdout))
42
88
 
43
- # Reader thread: reads NDJSON lines from stdin
44
89
  @reader = Thread.new do
45
- while @running
46
- begin
47
- line = $stdin.gets
48
- break unless line
49
- line = line.strip
50
- next if line.empty?
51
-
52
- @input_queue << JSON.parse(line)
53
- rescue JSON::ParserError => e
54
- send_error(nil, -32700, "Parse error: #{e.message}")
55
- rescue => e
56
- @logger.error("Reader error: #{e.message}")
57
- break
58
- end
59
- end
60
- @input_queue << nil # signal shutdown
61
- end
62
-
63
- # Event push thread: polls subscribed sessions and pushes notifications
64
- @pusher = Thread.new do
65
- while @running
66
- push_session_events
67
- sleep 0.1 # 100ms poll interval
68
- end
90
+ reader_loop(connection)
69
91
  end
92
+ @pusher = Thread.new { pusher_loop }
70
93
 
71
- # Main loop: processes incoming messages
72
- while @running
73
- msg = @input_queue.pop
74
- break if msg.nil?
75
-
76
- handle_message(msg)
77
- end
78
- rescue => e
79
- @logger.error("Server error: #{e.message}")
80
- raise
81
- ensure
82
- @running = false
83
- @reader&.kill rescue nil
84
- @pusher&.kill rescue nil
94
+ @reader.join
95
+ stop
85
96
  end
86
97
 
87
98
  # Stop the server.
88
99
  def stop
89
100
  @running = false
101
+ @reader&.kill rescue nil
102
+ @pusher&.kill rescue nil
90
103
  end
91
104
 
92
105
  # Whether the server is running.
@@ -94,52 +107,178 @@ module Ask
94
107
  @running
95
108
  end
96
109
 
97
- # Send an outgoing JSON-RPC request to the client.
110
+ # ── Dispatch ───────────────────────────────────────────────────────
111
+
112
+ # Process one JSON-RPC message from a connection. Requests are
113
+ # validated against the contract before dispatch; responses and
114
+ # errors are written back to `connection`.
115
+ #
116
+ # @param msg [Hash] parsed JSON-RPC message
117
+ # @param connection [Connection] the sending connection
118
+ def dispatch(msg, connection)
119
+ id = msg["id"] || msg[:id]
120
+
121
+ # Check if this is a response to an outgoing request.
122
+ # A response has an id and a result (or error), but no method.
123
+ if id && !msg.key?("method") && !msg.key?(:method)
124
+ if msg.key?("result") || msg.key?(:result)
125
+ handle_incoming_response(id, msg["result"] || msg[:result])
126
+ return
127
+ elsif msg.key?("error") || msg.key?(:error)
128
+ handle_incoming_response(id, nil, msg["error"] || msg[:error])
129
+ return
130
+ end
131
+ end
132
+
133
+ method = msg["method"] || msg[:method]
134
+ params = (msg["params"] || msg[:params] || {})
135
+ params = params.transform_keys(&:to_s) if params.is_a?(Hash)
136
+
137
+ unless method
138
+ send_error(id, -32600, "Method not specified", connection) if id
139
+ return
140
+ end
141
+
142
+ handler_block = @handlers[method]
143
+ unless handler_block
144
+ send_error(id, -32601, "Method not found: #{method}", connection) if id
145
+ return
146
+ end
147
+
148
+ begin
149
+ validate_request!(method, params)
150
+ result = handler_block.call(params, id, connection)
151
+ send_result(id, result, connection) if id
152
+ rescue Ask::AppServer::SessionNotFound => e
153
+ send_error(id, -32004, e.message, connection) if id
154
+ rescue Ask::AppServer::SessionAlreadyExists => e
155
+ send_error(id, -32005, e.message, connection) if id
156
+ rescue Ask::AppServer::InteractionNotFound => e
157
+ send_error(id, -32006, e.message, connection) if id
158
+ rescue Ask::AppServer::PlanNotFound => e
159
+ send_error(id, -32007, e.message, connection) if id
160
+ rescue Ask::AppServer::InvalidRequest => e
161
+ send_error(id, -32602, e.message, connection) if id
162
+ rescue ArgumentError => e
163
+ # Contract validation failures (invalid params)
164
+ send_error(id, -32602, e.message, connection) if id
165
+ rescue => e
166
+ @logger.error("Handler error for #{method}: #{e.message}")
167
+ send_error(id, -32603, "Internal error: #{e.message}", connection) if id
168
+ end
169
+ end
170
+
171
+ # Send an outgoing JSON-RPC request to a client (reverse request).
98
172
  # If a block is given, it will be called with (result, error) when
99
- # the client responds.
100
- def send_request(method, params, &block)
173
+ # the client responds. Defaults to the stdio transport ($stdout).
174
+ def send_request(method, params, connection = nil, &block)
101
175
  id = next_outgoing_id
102
176
  @response_handlers[id] = block if block
103
- write_line({ id: id, method: method, params: params })
177
+ if connection
178
+ connection.write({ id: id, method: method, params: params })
179
+ else
180
+ write_line({ id: id, method: method, params: params })
181
+ end
104
182
  id
105
183
  end
106
184
 
107
- # Register a PermissionHandler so the server can wire its protocol sender.
108
- # The server will set up the handler's on_request callback to send
109
- # interaction/requestPermission messages and route responses back.
110
- def register_permission_handler(handler)
111
- handler.on_request do |request_id, tool_name, arguments|
112
- send_request("interaction/requestPermission", {
113
- requestId: request_id,
114
- toolName: tool_name,
115
- input: arguments,
116
- riskLevel: blocked_tool_risk_level(tool_name),
117
- reason: "Tool '#{tool_name}' requires approval"
118
- }) do |result, error|
119
- if result
120
- decision = result["decision"] || result[:decision] || "deny"
121
- handler.handle_response(request_id, decision)
122
- else
123
- handler.handle_response(request_id, "deny", reason: error&.dig("message"))
185
+ # ── Event push ─────────────────────────────────────────────────────
186
+
187
+ # One delivery pass: for every subscribed connection, deliver the
188
+ # events its sessions have produced since the connection's cursor,
189
+ # then advance the cursor. Cursor-based, so each client receives
190
+ # exactly the events after its own seq (replay on subscribe).
191
+ def push_pending
192
+ connections.each do |connection|
193
+ connection.subscriptions.keys.each do |session_id|
194
+ adapter = @session_manager.get(session_id)
195
+ next unless adapter
196
+
197
+ events = adapter.events_after(connection.cursor(session_id))
198
+ next if events.empty?
199
+
200
+ events.each do |ev|
201
+ connection.write({ method: "session/event", params: { event: ev.to_h } })
124
202
  end
203
+ connection.advance(session_id, events.last.seq)
125
204
  end
126
205
  end
206
+ rescue => e
207
+ @logger.debug("Push error: #{e.message}") if ENV["DEBUG"]
208
+ end
209
+
210
+ # ── Response/notification helpers ──────────────────────────────────
211
+
212
+ def send_result(id, result, connection = nil)
213
+ connection ? connection.write({ id: id, result: result }) : write_line({ id: id, result: result })
214
+ end
215
+
216
+ def send_error(id, code, message, connection = nil)
217
+ response = { id: id, error: { code: code, message: message } }
218
+ connection ? connection.write(response) : write_line(response)
219
+ end
220
+
221
+ def send_notification(method, params, connection = nil)
222
+ msg = { method: method, params: params }
223
+ connection ? connection.write(msg) : write_line(msg)
224
+ end
225
+
226
+ def write_line(msg)
227
+ $stdout.puts(JSON.generate(msg))
228
+ $stdout.flush
127
229
  end
128
230
 
129
231
  private
130
232
 
233
+ def validate_request!(method, params)
234
+ # Host-side contract enforcement: canonical client → host methods
235
+ # validate their params against the protocol registry. Handlers
236
+ # outside the canonical surface (e.g. the requestPermission query)
237
+ # are exempt.
238
+ return unless Ask::SessionProtocol::Methods.known?(method)
239
+
240
+ Ask::SessionProtocol::Methods.validate_params!(method, params)
241
+ end
242
+
131
243
  def next_outgoing_id
132
244
  @outgoing_id += 1
133
245
  # Use IDs starting from a high number to avoid collision with client IDs
134
246
  10_000 + @outgoing_id
135
247
  end
136
248
 
137
- def blocked_tool_risk_level(tool_name)
138
- case tool_name.to_s
139
- when "bash" then "high"
140
- when "write", "edit" then "medium"
141
- when "destroy" then "critical"
142
- else "medium"
249
+ def reader_loop(connection)
250
+ while @running
251
+ line = connection.read_line
252
+ break unless line
253
+ line = line.strip
254
+ next if line.empty?
255
+
256
+ begin
257
+ dispatch(JSON.parse(line), connection)
258
+ rescue JSON::ParserError => e
259
+ send_error(nil, -32700, "Parse error: #{e.message}", connection)
260
+ rescue => e
261
+ @logger.error("Reader error: #{e.message}")
262
+ break
263
+ end
264
+ end
265
+ ensure
266
+ remove_connection(connection)
267
+ end
268
+
269
+ def pusher_loop
270
+ while @running
271
+ push_pending
272
+ sleep 0.1 # 100ms poll interval
273
+ end
274
+ end
275
+
276
+ def handle_incoming_response(id, result, error = nil)
277
+ handler_block = @response_handlers.delete(id)
278
+ if handler_block
279
+ handler_block.call(result, error)
280
+ else
281
+ @logger.debug("No handler for response #{id}")
143
282
  end
144
283
  end
145
284
 
@@ -148,23 +287,20 @@ module Ask
148
287
  handler("ping") do |_params, _id|
149
288
  {
150
289
  status: "ok",
151
- uptime: @started_at ? (Time.now - @started_at).to_i : 0,
152
290
  version: Ask::AppServer::VERSION,
291
+ protocolVersion: Ask::SessionProtocol::PROTOCOL_VERSION,
292
+ uptime: @started_at ? (Time.now - @started_at).to_i : 0,
153
293
  sessions: @session_manager&.store&.count || 0
154
294
  }
155
295
  end
156
296
 
157
- # Initialize handshake
158
- handler("initialize") do |params, _id|
297
+ # Initialize handshake — negotiate the protocol version and
298
+ # exchange capabilities.
299
+ handler("initialize") do |_params, _id|
159
300
  {
160
- protocolVersion: "2025-01-01",
161
- capabilities: {
162
- sessionManagement: true,
163
- eventStreaming: true,
164
- midExecutionInjection: true,
165
- permissions: true
166
- },
167
- serverInfo: {
301
+ protocolVersion: Ask::SessionProtocol::PROTOCOL_VERSION,
302
+ capabilities: HOST_CAPABILITIES,
303
+ server: {
168
304
  name: "ask-app-server",
169
305
  version: Ask::AppServer::VERSION
170
306
  }
@@ -222,35 +358,36 @@ module Ask
222
358
  }
223
359
  end
224
360
 
225
- # Session: subscribe
226
- handler("session/subscribe") do |params, _id|
361
+ # Session: subscribe — attach to the event stream with replay.
362
+ # The connection's delivery cursor starts at afterSeq, so the
363
+ # pusher delivers everything the client hasn't seen.
364
+ handler("session/subscribe") do |params, _id, connection|
227
365
  session_id = params["sessionId"] || params[:sessionId]
228
- delivery_kind = params["deliveryKind"] || params[:deliveryKind] || "web-remote-replayable"
366
+ delivery_kind = params["deliveryKind"] || params[:deliveryKind] || "replay"
229
367
  after_seq = params["afterSeq"] || params[:afterSeq] || 0
230
368
  include_snapshot = params["includeSnapshot"] || params[:includeSnapshot] || false
231
369
 
232
370
  result = @session_manager.subscribe(session_id, delivery_kind: delivery_kind)
371
+ connection.subscribe(session_id, after_seq: after_seq)
233
372
 
234
- snapshot = if include_snapshot
235
- @session_manager.get_events(session_id, after_seq: after_seq)
236
- else
237
- nil
373
+ if include_snapshot
374
+ snapshot = @session_manager.get_events(session_id, after_seq: after_seq)
375
+ result[:snapshot] = serialize_events(snapshot[:events])
238
376
  end
239
377
 
240
- result.merge(snapshot: snapshot).compact
378
+ result
241
379
  end
242
380
 
243
- # Session: send
381
+ # Session: send — prompt an idle session or inject mid-run.
244
382
  handler("session/send") do |params, _id|
245
383
  session_id = params["sessionId"] || params[:sessionId]
246
384
  content = params["content"] || params[:content]
385
+ expected_turn_id = params["expectedTurnId"] || params[:expectedTurnId]
247
386
 
248
387
  raise InvalidRequest, "sessionId is required" unless session_id
249
388
  raise InvalidRequest, "content is required" unless content
250
389
 
251
- @session_manager.send_message(session_id, content.to_s)
252
-
253
- { accepted: true, sessionId: session_id }
390
+ @session_manager.send_message(session_id, content, expected_turn_id: expected_turn_id)
254
391
  end
255
392
 
256
393
  # Session: events (polling)
@@ -259,7 +396,9 @@ module Ask
259
396
  after_seq = params["afterSeq"] || params[:afterSeq] || 0
260
397
  limit = params["limit"] || params[:limit]
261
398
 
262
- @session_manager.get_events(session_id, after_seq: after_seq, limit: limit)
399
+ result = @session_manager.get_events(session_id, after_seq: after_seq, limit: limit)
400
+ result[:events] = serialize_events(result[:events])
401
+ result
263
402
  end
264
403
 
265
404
  # Session: abort
@@ -274,6 +413,17 @@ module Ask
274
413
  { aborted: true, sessionId: session_id }
275
414
  end
276
415
 
416
+ # Session: close
417
+ handler("session/close") do |params, _id|
418
+ session_id = params["sessionId"] || params[:sessionId]
419
+ raise InvalidRequest, "sessionId is required" unless session_id
420
+
421
+ closed = @session_manager.close_session(session_id)
422
+ raise Ask::AppServer::SessionNotFound, "Session #{session_id} not found" unless closed
423
+
424
+ { closed: true, sessionId: session_id }
425
+ end
426
+
277
427
  # Artifacts: list the session's tool deliverables
278
428
  handler("session/artifacts") do |params, _id|
279
429
  session_id = params["sessionId"] || params[:sessionId]
@@ -307,111 +457,109 @@ module Ask
307
457
  { artifact: record }
308
458
  end
309
459
 
310
- # Workspace: read state
311
- handler("workspace/readState") do |params, _id|
312
- @session_manager.read_workspace_state
313
- end
460
+ # ── Interactions (resolvable by id from any client) ──────────────
314
461
 
315
- # Default handler for interaction/requestPermission
316
- # This is both an incoming request from the client (to query current
317
- # permission state) and the client may also respond to our outgoing
318
- # permission requests via the response routing in handle_message.
319
- handler("interaction/requestPermission") do |params, _id|
320
- # If the client sends this as a request, respond with current state
321
- { mode: @session_manager.permission_mode, pending: false }
462
+ # Interaction: list pending approval interactions
463
+ handler("interaction/list") do |params, _id|
464
+ session_id = params["sessionId"] || params[:sessionId]
465
+ raise InvalidRequest, "sessionId is required" unless session_id
466
+
467
+ interactions = @session_manager.pending_interactions(session_id)
468
+ { interactions: interactions.map(&:to_h) }
322
469
  end
323
- end
324
470
 
325
- def handler(method, &block)
326
- @handlers[method] = block
327
- end
471
+ # Interaction: approve by id
472
+ handler("interaction/approve") do |params, _id|
473
+ session_id = params["sessionId"] || params[:sessionId]
474
+ interaction_id = params["interactionId"] || params[:interactionId]
475
+ raise InvalidRequest, "sessionId and interactionId are required" unless session_id && interaction_id
328
476
 
329
- def handle_message(msg)
330
- id = msg["id"] || msg[:id]
477
+ approved = @session_manager.approve_interaction(session_id, interaction_id)
478
+ raise Ask::AppServer::InteractionNotFound, "Interaction #{interaction_id} not found" unless approved
331
479
 
332
- # Check if this is a response to an outgoing request.
333
- # A response has an id and a result (or error), but no method.
334
- if id && !msg.key?("method") && !msg.key?(:method)
335
- if msg.key?("result") || msg.key?(:result)
336
- handle_incoming_response(id, msg["result"] || msg[:result])
337
- return
338
- elsif msg.key?("error") || msg.key?(:error)
339
- handle_incoming_response(id, nil, msg["error"] || msg[:error])
340
- return
341
- end
480
+ { approved: true, interactionId: interaction_id }
342
481
  end
343
482
 
344
- method = msg["method"] || msg[:method]
345
- params = msg["params"] || msg[:params] || {}
483
+ # Interaction: reject by id
484
+ handler("interaction/reject") do |params, _id|
485
+ session_id = params["sessionId"] || params[:sessionId]
486
+ interaction_id = params["interactionId"] || params[:interactionId]
487
+ raise InvalidRequest, "sessionId and interactionId are required" unless session_id && interaction_id
346
488
 
347
- unless method
348
- send_error(id, -32600, "Method not specified") if id
349
- return
489
+ rejected = @session_manager.reject_interaction(session_id, interaction_id)
490
+ raise Ask::AppServer::InteractionNotFound, "Interaction #{interaction_id} not found" unless rejected
491
+
492
+ { rejected: true, interactionId: interaction_id }
350
493
  end
351
494
 
352
- handler_block = @handlers[method]
353
- unless handler_block
354
- send_error(id, -32601, "Method not found: #{method}") if id
355
- return
495
+ # Interaction: approve all pending
496
+ handler("interaction/approve-all") do |params, _id|
497
+ session_id = params["sessionId"] || params[:sessionId]
498
+ raise InvalidRequest, "sessionId is required" unless session_id
499
+
500
+ { approved: @session_manager.approve_all_interactions(session_id) }
356
501
  end
357
502
 
358
- begin
359
- result = handler_block.call(params, id)
360
- send_result(id, result) if id
361
- rescue Ask::AppServer::SessionNotFound => e
362
- send_error(id, -32004, e.message) if id
363
- rescue Ask::AppServer::SessionAlreadyExists => e
364
- send_error(id, -32005, e.message) if id
365
- rescue Ask::AppServer::InvalidRequest => e
366
- send_error(id, -32602, e.message) if id
367
- rescue => e
368
- @logger.error("Handler error for #{method}: #{e.message}")
369
- send_error(id, -32603, "Internal error: #{e.message}") if id
503
+ # Interaction: reject all pending
504
+ handler("interaction/reject-all") do |params, _id|
505
+ session_id = params["sessionId"] || params[:sessionId]
506
+ raise InvalidRequest, "sessionId is required" unless session_id
507
+
508
+ { rejected: @session_manager.reject_all_interactions(session_id) }
370
509
  end
371
- end
372
510
 
373
- def handle_incoming_response(id, result, error = nil)
374
- handler_block = @response_handlers.delete(id)
375
- if handler_block
376
- handler_block.call(result, error)
377
- else
378
- @logger.debug("No handler for response #{id}")
511
+ # Interaction: respond to a user_input interaction (elicitation).
512
+ # Not implemented: ask-agent has no elicitation surface yet.
513
+ handler("interaction/respond") do |_params, id, connection|
514
+ send_error(id, Ask::SessionProtocol::Methods::ERROR_CODES[:not_implemented],
515
+ "interaction/respond is not implemented: user input elicitation is not supported by the runtime",
516
+ connection)
379
517
  end
380
- end
381
518
 
382
- def send_result(id, result)
383
- write_line({ id: id, result: result })
384
- end
519
+ # ── Plan mode ────────────────────────────────────────────────────
385
520
 
386
- def send_error(id, code, message)
387
- response = { id: id, error: { code: code, message: message } }
388
- write_line(response)
389
- end
521
+ # Plan: approve the pending proposal
522
+ handler("plan/approve") do |params, _id|
523
+ session_id = params["sessionId"] || params[:sessionId]
524
+ raise InvalidRequest, "sessionId is required" unless session_id
390
525
 
391
- def send_notification(method, params)
392
- write_line({ method: method, params: params })
393
- end
526
+ approved = @session_manager.plan_approve(session_id)
527
+ raise Ask::AppServer::PlanNotFound, "No pending plan for session #{session_id}" unless approved
394
528
 
395
- def write_line(msg)
396
- $stdout.puts(JSON.generate(msg))
397
- $stdout.flush
398
- end
529
+ { approved: true }
530
+ end
399
531
 
400
- # Push session/event notifications for subscribed sessions.
401
- def push_session_events
402
- @session_manager.store.each do |adapter|
403
- sid = adapter.session_id
404
- next unless @session_manager.subscribed?(sid)
532
+ # Plan: reject the pending proposal
533
+ handler("plan/reject") do |params, _id|
534
+ session_id = params["sessionId"] || params[:sessionId]
535
+ raise InvalidRequest, "sessionId is required" unless session_id
405
536
 
406
- events = adapter.drain_events
407
- next if events.empty?
537
+ rejected = @session_manager.plan_reject(session_id)
538
+ raise Ask::AppServer::PlanNotFound, "No pending plan for session #{session_id}" unless rejected
408
539
 
409
- events.each do |ev|
410
- send_notification("session/event", ev)
411
- end
540
+ { rejected: true }
412
541
  end
413
- rescue => e
414
- @logger.debug("Push error: #{e.message}") if ENV["DEBUG"]
542
+
543
+ # Workspace: read state
544
+ handler("workspace/readState") do |params, _id|
545
+ session_id = params["sessionId"] || params[:sessionId]
546
+ @session_manager.read_workspace_state(session_id)
547
+ end
548
+
549
+ # Interaction: requestPermission — when a client sends this as a
550
+ # request, respond with the current approval mode (interop query).
551
+ handler("interaction/requestPermission") do |_params, _id|
552
+ { mode: @session_manager.approval_mode.to_s, pending: false }
553
+ end
554
+ end
555
+
556
+ def handler(method, &block)
557
+ @handlers[method] = block
558
+ end
559
+
560
+ # Canonical Event objects → wire hashes.
561
+ def serialize_events(events)
562
+ events.map(&:to_h)
415
563
  end
416
564
  end
417
565
  end