ask-app-server 0.1.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,384 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Ask
6
+ module AppServer
7
+ # JSON-RPC server implementing the ZCode/Codex app-server protocol over stdio.
8
+ #
9
+ # Communicates via NDJSON (Newline-Delimited JSON) over stdin/stdout.
10
+ # Supports session management, streaming events, and mid-execution injection.
11
+ #
12
+ # Protocol methods:
13
+ # session/create, session/list, session/resume, session/subscribe,
14
+ # session/send, session/events, session/abort, workspace/readState
15
+ #
16
+ # Notifications (server → client):
17
+ # session/event, interaction/requestPermission, interaction/requestUserInput
18
+ #
19
+ # The server also handles incoming responses to its outgoing requests
20
+ # (e.g., client responses to interaction/requestPermission).
21
+ class Server
22
+ def initialize(session_manager: nil)
23
+ @session_manager = session_manager || SessionManager.new
24
+ @running = false
25
+ @started_at = nil
26
+ @input_queue = Queue.new
27
+ @response_handlers = {} # outgoing request_id => Proc
28
+ @outgoing_id = 0
29
+ @logger = Logger.new($stdout, level: ENV["DEBUG"] ? Logger::DEBUG : Logger::WARN)
30
+
31
+ # Register the protocol method handlers
32
+ @handlers = {}
33
+ register_default_handlers
34
+ end
35
+
36
+ # Start the server — reads from stdin in a background thread.
37
+ # The main thread handles event pushing and shutdown.
38
+ def start
39
+ @running = true
40
+ @started_at = Time.now
41
+
42
+ # Reader thread: reads NDJSON lines from stdin
43
+ @reader = Thread.new do
44
+ while @running
45
+ begin
46
+ line = $stdin.gets
47
+ break unless line
48
+ line = line.strip
49
+ next if line.empty?
50
+
51
+ @input_queue << JSON.parse(line)
52
+ rescue JSON::ParserError => e
53
+ send_error(nil, -32700, "Parse error: #{e.message}")
54
+ rescue => e
55
+ @logger.error("Reader error: #{e.message}")
56
+ break
57
+ end
58
+ end
59
+ @input_queue << nil # signal shutdown
60
+ end
61
+
62
+ # Event push thread: polls subscribed sessions and pushes notifications
63
+ @pusher = Thread.new do
64
+ while @running
65
+ push_session_events
66
+ sleep 0.1 # 100ms poll interval
67
+ end
68
+ end
69
+
70
+ # Main loop: processes incoming messages
71
+ while @running
72
+ msg = @input_queue.pop
73
+ break if msg.nil?
74
+
75
+ handle_message(msg)
76
+ end
77
+ rescue => e
78
+ @logger.error("Server error: #{e.message}")
79
+ raise
80
+ ensure
81
+ @running = false
82
+ @reader&.kill rescue nil
83
+ @pusher&.kill rescue nil
84
+ end
85
+
86
+ # Stop the server.
87
+ def stop
88
+ @running = false
89
+ end
90
+
91
+ # Whether the server is running.
92
+ def running?
93
+ @running
94
+ end
95
+
96
+ # Send an outgoing JSON-RPC request to the client.
97
+ # If a block is given, it will be called with (result, error) when
98
+ # the client responds.
99
+ def send_request(method, params, &block)
100
+ id = next_outgoing_id
101
+ @response_handlers[id] = block if block
102
+ write_line({ id: id, method: method, params: params })
103
+ id
104
+ end
105
+
106
+ # Register a PermissionHandler so the server can wire its protocol sender.
107
+ # The server will set up the handler's on_request callback to send
108
+ # interaction/requestPermission messages and route responses back.
109
+ def register_permission_handler(handler)
110
+ handler.on_request do |request_id, tool_name, arguments|
111
+ send_request("interaction/requestPermission", {
112
+ requestId: request_id,
113
+ toolName: tool_name,
114
+ input: arguments,
115
+ riskLevel: blocked_tool_risk_level(tool_name),
116
+ reason: "Tool '#{tool_name}' requires approval"
117
+ }) do |result, error|
118
+ if result
119
+ decision = result["decision"] || result[:decision] || "deny"
120
+ handler.handle_response(request_id, decision)
121
+ else
122
+ handler.handle_response(request_id, "deny", reason: error&.dig("message"))
123
+ end
124
+ end
125
+ end
126
+ end
127
+
128
+ private
129
+
130
+ def next_outgoing_id
131
+ @outgoing_id += 1
132
+ # Use IDs starting from a high number to avoid collision with client IDs
133
+ 10_000 + @outgoing_id
134
+ end
135
+
136
+ def blocked_tool_risk_level(tool_name)
137
+ case tool_name.to_s
138
+ when "bash" then "high"
139
+ when "write", "edit" then "medium"
140
+ when "destroy" then "critical"
141
+ else "medium"
142
+ end
143
+ end
144
+
145
+ def register_default_handlers
146
+ # Ping — liveness check
147
+ handler("ping") do |_params, _id|
148
+ {
149
+ status: "ok",
150
+ uptime: @started_at ? (Time.now - @started_at).to_i : 0,
151
+ version: Ask::AppServer::VERSION,
152
+ sessions: @session_manager&.store&.count || 0
153
+ }
154
+ end
155
+
156
+ # Initialize handshake
157
+ handler("initialize") do |params, _id|
158
+ {
159
+ protocolVersion: "2025-01-01",
160
+ capabilities: {
161
+ sessionManagement: true,
162
+ eventStreaming: true,
163
+ midExecutionInjection: true,
164
+ permissions: true
165
+ },
166
+ serverInfo: {
167
+ name: "ask-app-server",
168
+ version: Ask::AppServer::VERSION
169
+ }
170
+ }
171
+ end
172
+
173
+ # Session: create
174
+ handler("session/create") do |params, _id|
175
+ workspace = params["workspace"] || params[:workspace] || {}
176
+ workspace_path = workspace["workspacePath"] || workspace[:workspacePath]
177
+ mode = params["mode"] || params[:mode]
178
+ model = params["model"] || params[:model] || ENV["ASK_APP_SERVER_MODEL"]
179
+ tools = params["tools"] || params[:tools]
180
+ system_prompt = params["systemPrompt"] || params[:system_prompt]
181
+
182
+ session_id = @session_manager.create_session(
183
+ workspace_path: workspace_path,
184
+ mode: mode,
185
+ model: model,
186
+ tools: tools,
187
+ system_prompt: system_prompt
188
+ )
189
+
190
+ adapter = @session_manager.get(session_id)
191
+
192
+ {
193
+ session: {
194
+ sessionId: session_id,
195
+ model: adapter&.instance_variable_get(:@model) || model,
196
+ createdAt: adapter&.created_at&.iso8601
197
+ }
198
+ }
199
+ end
200
+
201
+ # Session: list
202
+ handler("session/list") do |params, _id|
203
+ limit = params["limit"] || params[:limit] || 20
204
+ sessions = @session_manager.list_sessions(limit: limit)
205
+ { sessions: sessions }
206
+ end
207
+
208
+ # Session: resume
209
+ handler("session/resume") do |params, _id|
210
+ session_id = params["sessionId"] || params[:sessionId]
211
+ raise InvalidRequest, "sessionId is required" unless session_id
212
+
213
+ adapter = @session_manager.get(session_id)
214
+ raise Ask::AppServer::SessionNotFound, "Session #{session_id} not found" unless adapter
215
+
216
+ {
217
+ sessionId: session_id,
218
+ running: adapter.running,
219
+ idle: adapter.idle?,
220
+ createdAt: adapter.created_at.iso8601
221
+ }
222
+ end
223
+
224
+ # Session: subscribe
225
+ handler("session/subscribe") do |params, _id|
226
+ session_id = params["sessionId"] || params[:sessionId]
227
+ delivery_kind = params["deliveryKind"] || params[:deliveryKind] || "web-remote-replayable"
228
+ after_seq = params["afterSeq"] || params[:afterSeq] || 0
229
+ include_snapshot = params["includeSnapshot"] || params[:includeSnapshot] || false
230
+
231
+ result = @session_manager.subscribe(session_id, delivery_kind: delivery_kind)
232
+
233
+ snapshot = if include_snapshot
234
+ @session_manager.get_events(session_id, after_seq: after_seq)
235
+ else
236
+ nil
237
+ end
238
+
239
+ result.merge(snapshot: snapshot).compact
240
+ end
241
+
242
+ # Session: send
243
+ handler("session/send") do |params, _id|
244
+ session_id = params["sessionId"] || params[:sessionId]
245
+ content = params["content"] || params[:content]
246
+
247
+ raise InvalidRequest, "sessionId is required" unless session_id
248
+ raise InvalidRequest, "content is required" unless content
249
+
250
+ @session_manager.send_message(session_id, content.to_s)
251
+
252
+ { accepted: true, sessionId: session_id }
253
+ end
254
+
255
+ # Session: events (polling)
256
+ handler("session/events") do |params, _id|
257
+ session_id = params["sessionId"] || params[:sessionId]
258
+ after_seq = params["afterSeq"] || params[:afterSeq] || 0
259
+ limit = params["limit"] || params[:limit]
260
+
261
+ @session_manager.get_events(session_id, after_seq: after_seq, limit: limit)
262
+ end
263
+
264
+ # Session: abort
265
+ handler("session/abort") do |params, _id|
266
+ session_id = params["sessionId"] || params[:sessionId]
267
+ raise InvalidRequest, "sessionId is required" unless session_id
268
+
269
+ adapter = @session_manager.get(session_id)
270
+ raise Ask::AppServer::SessionNotFound, "Session #{session_id} not found" unless adapter
271
+
272
+ adapter.abort_turn!
273
+ { aborted: true, sessionId: session_id }
274
+ end
275
+
276
+ # Workspace: read state
277
+ handler("workspace/readState") do |params, _id|
278
+ @session_manager.read_workspace_state
279
+ end
280
+
281
+ # Default handler for interaction/requestPermission
282
+ # This is both an incoming request from the client (to query current
283
+ # permission state) and the client may also respond to our outgoing
284
+ # permission requests via the response routing in handle_message.
285
+ handler("interaction/requestPermission") do |params, _id|
286
+ # If the client sends this as a request, respond with current state
287
+ { mode: @session_manager.permission_mode, pending: false }
288
+ end
289
+ end
290
+
291
+ def handler(method, &block)
292
+ @handlers[method] = block
293
+ end
294
+
295
+ def handle_message(msg)
296
+ id = msg["id"] || msg[:id]
297
+
298
+ # Check if this is a response to an outgoing request.
299
+ # A response has an id and a result (or error), but no method.
300
+ if id && !msg.key?("method") && !msg.key?(:method)
301
+ if msg.key?("result") || msg.key?(:result)
302
+ handle_incoming_response(id, msg["result"] || msg[:result])
303
+ return
304
+ elsif msg.key?("error") || msg.key?(:error)
305
+ handle_incoming_response(id, nil, msg["error"] || msg[:error])
306
+ return
307
+ end
308
+ end
309
+
310
+ method = msg["method"] || msg[:method]
311
+ params = msg["params"] || msg[:params] || {}
312
+
313
+ unless method
314
+ send_error(id, -32600, "Method not specified") if id
315
+ return
316
+ end
317
+
318
+ handler_block = @handlers[method]
319
+ unless handler_block
320
+ send_error(id, -32601, "Method not found: #{method}") if id
321
+ return
322
+ end
323
+
324
+ begin
325
+ result = handler_block.call(params, id)
326
+ send_result(id, result) if id
327
+ rescue Ask::AppServer::SessionNotFound => e
328
+ send_error(id, -32004, e.message) if id
329
+ rescue Ask::AppServer::SessionAlreadyExists => e
330
+ send_error(id, -32005, e.message) if id
331
+ rescue Ask::AppServer::InvalidRequest => e
332
+ send_error(id, -32602, e.message) if id
333
+ rescue => e
334
+ @logger.error("Handler error for #{method}: #{e.message}")
335
+ send_error(id, -32603, "Internal error: #{e.message}") if id
336
+ end
337
+ end
338
+
339
+ def handle_incoming_response(id, result, error = nil)
340
+ handler_block = @response_handlers.delete(id)
341
+ if handler_block
342
+ handler_block.call(result, error)
343
+ else
344
+ @logger.debug("No handler for response #{id}")
345
+ end
346
+ end
347
+
348
+ def send_result(id, result)
349
+ write_line({ id: id, result: result })
350
+ end
351
+
352
+ def send_error(id, code, message)
353
+ response = { id: id, error: { code: code, message: message } }
354
+ write_line(response)
355
+ end
356
+
357
+ def send_notification(method, params)
358
+ write_line({ method: method, params: params })
359
+ end
360
+
361
+ def write_line(msg)
362
+ $stdout.puts(JSON.generate(msg))
363
+ $stdout.flush
364
+ end
365
+
366
+ # Push session/event notifications for subscribed sessions.
367
+ def push_session_events
368
+ @session_manager.store.each do |adapter|
369
+ sid = adapter.session_id
370
+ next unless @session_manager.subscribed?(sid)
371
+
372
+ events = adapter.drain_events
373
+ next if events.empty?
374
+
375
+ events.each do |ev|
376
+ send_notification("session/event", ev)
377
+ end
378
+ end
379
+ rescue => e
380
+ @logger.debug("Push error: #{e.message}") if ENV["DEBUG"]
381
+ end
382
+ end
383
+ end
384
+ end
@@ -0,0 +1,237 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ask
4
+ module AppServer
5
+ # Manages the lifecycle of agent sessions exposed through the app-server.
6
+ #
7
+ # Orchestrates creation, resumption, subscription, messaging, aborting,
8
+ # and event polling across AgentAdapter instances stored in SessionStore.
9
+ class SessionManager
10
+ # Default tools if none specified.
11
+ DEFAULT_TOOLS = %w[bash read write edit glob grep].freeze
12
+
13
+ # Default model if none specified.
14
+ DEFAULT_MODEL = ENV.fetch("ASK_APP_SERVER_MODEL", "opencode_go/deepseek-v4-flash")
15
+
16
+ attr_reader :store
17
+ attr_reader :permission_mode
18
+ attr_reader :blocked_tools
19
+ attr_reader :permission_timeout
20
+
21
+ def initialize(store: nil, permission_mode: :on_request, blocked_tools: nil, permission_timeout: 300)
22
+ @store = store || SessionStore.new
23
+ @permission_mode = permission_mode
24
+ @blocked_tools = blocked_tools
25
+ @permission_timeout = permission_timeout
26
+ @permission_handlers = {} # session_id => PermissionHandler
27
+ @on_new_handler = nil # callback for new permission handlers
28
+ @logger = Logger.new($stdout, level: ENV["DEBUG"] ? Logger::DEBUG || Logger::DEBUG : Logger::WARN)
29
+ end
30
+
31
+ # Register a callback that fires for every new PermissionHandler created.
32
+ # The server uses this to wire its protocol sender callback.
33
+ def on_new_permission_handler(&block)
34
+ @on_new_handler = block
35
+ end
36
+
37
+ # Set the permission mode for new sessions.
38
+ def permission_mode=(mode)
39
+ @permission_mode = mode.to_sym
40
+ end
41
+
42
+ # Create a new session.
43
+ # Returns the session ID.
44
+ def create_session(workspace_path: nil, mode: nil, model: nil, tools: nil, system_prompt: nil)
45
+ permission_mode = resolve_permission_mode(mode)
46
+
47
+ # Extract provider prefix from model string (e.g., "opencode_go/deepseek-v4-flash")
48
+ model_id, model_provider = parse_model_string(model || DEFAULT_MODEL)
49
+ model_for_agent = model_provider ? model_id : (model || DEFAULT_MODEL)
50
+
51
+ # Create the permission handler if needed
52
+ permission_handler = if permission_mode == :on_request
53
+ handler_opts = { mode: :on_request }
54
+ handler_opts[:blocked_tools] = @blocked_tools if @blocked_tools
55
+ handler_opts[:timeout] = @permission_timeout if @permission_timeout
56
+
57
+ handler = PermissionHandler.new(**handler_opts)
58
+ @permission_handlers[session_id_cache] = handler if session_id_cache
59
+ handler
60
+ end
61
+
62
+ hooks = {}
63
+ hooks[:before_tool] = [permission_handler] if permission_handler
64
+
65
+ adapter = AgentAdapter.new(
66
+ model: model_for_agent,
67
+ tools: tools || DEFAULT_TOOLS,
68
+ system_prompt: system_prompt || build_default_system_prompt(workspace_path),
69
+ agent_dir: workspace_path,
70
+ hooks: hooks
71
+ )
72
+
73
+ session_id = adapter.start_session
74
+ @store.add(session_id, adapter)
75
+
76
+ # Register the permission handler by session ID
77
+ if permission_handler
78
+ @permission_handlers[session_id] = permission_handler
79
+ @on_new_handler&.call(permission_handler)
80
+ end
81
+
82
+ @logger.info("Created session #{session_id} (model=#{model || DEFAULT_MODEL}, permission_mode=#{permission_mode})")
83
+
84
+ session_id
85
+ end
86
+
87
+ # Get the permission handler for a session, if any.
88
+ def permission_handler_for(session_id)
89
+ @permission_handlers[session_id]
90
+ end
91
+
92
+ # Remove a session.
93
+ def destroy_session(session_id)
94
+ adapter = @store.get(session_id)
95
+ return false unless adapter
96
+
97
+ # Cancel any pending permission requests
98
+ handler = @permission_handlers.delete(session_id)
99
+ handler&.cancel_all!
100
+
101
+ adapter.abort_turn! if adapter.running
102
+ @store.remove(session_id)
103
+ @logger.info("Destroyed session #{session_id}")
104
+ true
105
+ end
106
+
107
+ # Get an adapter by session ID.
108
+ def get(session_id)
109
+ @store.get(session_id)
110
+ end
111
+
112
+ # List active sessions.
113
+ def list_sessions(limit: 20)
114
+ @store.list(limit: limit)
115
+ end
116
+
117
+ # Send a message to a session.
118
+ def send_message(session_id, content)
119
+ adapter = @store.get(session_id)
120
+ raise Ask::AppServer::SessionNotFound, "Session #{session_id} not found" unless adapter
121
+
122
+ if adapter.running
123
+ @logger.info("Session #{session_id} busy, injecting message")
124
+ adapter.inject_message(content)
125
+ else
126
+ adapter.send_message(content)
127
+ end
128
+
129
+ true
130
+ end
131
+
132
+ # Subscribe to a session's events.
133
+ def subscribe(session_id, delivery_kind: "web-remote-replayable")
134
+ adapter = @store.get(session_id)
135
+ raise Ask::AppServer::SessionNotFound, "Session #{session_id} not found" unless adapter
136
+
137
+ @store.subscribe(session_id, delivery_kind: delivery_kind)
138
+ { subscribed: true, sessionId: session_id, deliveryKind: delivery_kind }
139
+ end
140
+
141
+ # Get events for a session after a sequence number.
142
+ def get_events(session_id, after_seq:, limit: nil)
143
+ adapter = @store.get(session_id)
144
+ raise Ask::AppServer::SessionNotFound, "Session #{session_id} not found" unless adapter
145
+
146
+ events = adapter.events_after(after_seq.to_i)
147
+ events = events.first(limit) if limit && limit > 0
148
+
149
+ {
150
+ sessionId: session_id,
151
+ events: events,
152
+ hasMore: limit && limit > 0 && events.length >= limit
153
+ }
154
+ end
155
+
156
+ # Read workspace state.
157
+ def read_workspace_state(session_id = nil)
158
+ settings = {
159
+ model: {
160
+ current: {
161
+ modelId: ENV.fetch("ASK_APP_SERVER_MODEL", DEFAULT_MODEL),
162
+ providerId: resolve_provider_id
163
+ }
164
+ },
165
+ permissions: {
166
+ mode: @permission_mode.to_s
167
+ }
168
+ }
169
+
170
+ if session_id
171
+ adapter = @store.get(session_id)
172
+ if adapter
173
+ settings[:sessionId] = session_id
174
+ settings[:running] = adapter.running
175
+ end
176
+ end
177
+
178
+ { settings: settings }
179
+ end
180
+
181
+ # Check if a session is subscribed.
182
+ def subscribed?(session_id)
183
+ @store.subscribed?(session_id)
184
+ end
185
+
186
+ # Notify subscribers of new events.
187
+ def pending_notifications(session_id)
188
+ adapter = @store.get(session_id)
189
+ return [] unless adapter && subscribed?(session_id)
190
+
191
+ adapter.drain_events
192
+ end
193
+
194
+ private
195
+
196
+ def resolve_permission_mode(mode)
197
+ return :never if mode.to_s == "yolo"
198
+ return :never if mode.to_s == "plan"
199
+ return :on_request if mode.to_s == "build"
200
+ return :on_request if mode.to_s == "edit"
201
+ @permission_mode
202
+ end
203
+
204
+ def build_default_system_prompt(workspace_path)
205
+ parts = ["You are a helpful AI coding assistant. You can use shell commands and file operations to help the user."]
206
+ parts << "Working directory: #{workspace_path}" if workspace_path
207
+ parts.join("\n")
208
+ end
209
+
210
+ def resolve_provider_id
211
+ model = ENV.fetch("ASK_APP_SERVER_MODEL", DEFAULT_MODEL)
212
+ case model
213
+ when /^gpt/, /^o\d/ then "openai"
214
+ when /^claude/ then "anthropic"
215
+ when /^gemini/ then "google"
216
+ else "openai"
217
+ end
218
+ end
219
+
220
+ # Temporary cache for session ID before it's created
221
+ def session_id_cache
222
+ @_sid_counter ||= 0
223
+ @_sid_counter += 1
224
+ "_pending_#{@_sid_counter}"
225
+ end
226
+
227
+ # Parse a model string that may include a provider prefix.
228
+ # "opencode_go/deepseek-v4-flash" => ["deepseek-v4-flash", "opencode_go"]
229
+ # "gpt-4o" => ["gpt-4o", nil]
230
+ def parse_model_string(str)
231
+ return [str, nil] unless str&.include?("/")
232
+ parts = str.split("/", 2)
233
+ [parts[1], parts[0]]
234
+ end
235
+ end
236
+ end
237
+ end