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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +15 -0
- data/LICENSE +21 -0
- data/README.md +108 -0
- data/bin/ask-app-server +6 -0
- data/lib/ask/app_server/agent_adapter.rb +206 -0
- data/lib/ask/app_server/cli.rb +173 -0
- data/lib/ask/app_server/config.rb +258 -0
- data/lib/ask/app_server/event_translator.rb +194 -0
- data/lib/ask/app_server/permission_handler.rb +167 -0
- data/lib/ask/app_server/server.rb +384 -0
- data/lib/ask/app_server/session_manager.rb +237 -0
- data/lib/ask/app_server/session_store.rb +138 -0
- data/lib/ask/app_server/version.rb +7 -0
- data/lib/ask/app_server.rb +13 -0
- data/lib/ask-app-server.rb +15 -0
- metadata +201 -0
|
@@ -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
|