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.
@@ -2,10 +2,12 @@
2
2
 
3
3
  module Ask
4
4
  module AppServer
5
- # Manages the lifecycle of agent sessions exposed through the app-server.
5
+ # Manages the lifecycle of agent sessions exposed through the session
6
+ # host (ask-app-server).
6
7
  #
7
8
  # Orchestrates creation, resumption, subscription, messaging, aborting,
8
- # and event polling across AgentAdapter instances stored in SessionStore.
9
+ # interaction resolution (approvals, plan), closing, and event polling
10
+ # across AgentAdapter instances stored in SessionStore.
9
11
  class SessionManager
10
12
  # Default tools if none specified.
11
13
  DEFAULT_TOOLS = %w[bash read write edit glob grep].freeze
@@ -13,6 +15,9 @@ module Ask
13
15
  # Default model if none specified.
14
16
  DEFAULT_MODEL = ENV.fetch("ASK_APP_SERVER_MODEL", "opencode_go/deepseek-v4-flash")
15
17
 
18
+ # Tools gated behind human approval when approval is :require.
19
+ DEFAULT_REQUIRE_APPROVAL = %w[write edit bash destroy].freeze
20
+
16
21
  attr_reader :store
17
22
  attr_reader :permission_mode
18
23
  attr_reader :blocked_tools
@@ -21,89 +26,86 @@ module Ask
21
26
  def initialize(store: nil, permission_mode: :on_request, blocked_tools: nil, permission_timeout: 300)
22
27
  @store = store || SessionStore.new
23
28
  @permission_mode = permission_mode
24
- @blocked_tools = blocked_tools
29
+ @blocked_tools = (blocked_tools || DEFAULT_REQUIRE_APPROVAL).map(&:to_s)
25
30
  @permission_timeout = permission_timeout
26
- @permission_handlers = {} # session_id => PermissionHandler
27
- @on_new_handler = nil # callback for new permission handlers
31
+ @session_event_observers = []
28
32
  @logger = Logger.new($stdout, level: ENV["DEBUG"] ? Logger::DEBUG || Logger::DEBUG : Logger::WARN)
29
33
  end
30
34
 
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
+ # Register an observer for canonical events across all sessions.
36
+ # Called with (session_id, Ask::SessionProtocol::Events::Event) for
37
+ # every emitted event, as it happens.
38
+ def on_session_event(&block)
39
+ @session_event_observers << block
35
40
  end
36
41
 
37
- # Set the permission mode for new sessions.
42
+ # Set the permission mode for new sessions (:on_request, :never, :auto).
38
43
  def permission_mode=(mode)
39
44
  @permission_mode = mode.to_sym
40
45
  end
41
46
 
47
+ # The approval mode new sessions get, translated to the canonical
48
+ # vocabulary (:off, :require, :auto).
49
+ def approval_mode
50
+ translate_permission_mode(@permission_mode)
51
+ end
52
+
42
53
  # Create a new session.
43
54
  # Returns the session ID.
55
+ #
56
+ # @param mode [String, Symbol, nil] "on_request"/"build"/"edit"
57
+ # (approvals required for blocked tools), "auto", "never"/"off"/"yolo"
58
+ # (no approvals), "plan" (plan mode, read-only until approved); nil
59
+ # falls back to the manager's permission_mode
44
60
  def create_session(workspace_path: nil, mode: nil, model: nil, tools: nil, system_prompt: nil)
45
- permission_mode = resolve_permission_mode(mode)
61
+ approval_opts, plan_mode = resolve_approval(mode)
46
62
 
47
63
  # Extract provider prefix from model string (e.g., "opencode_go/deepseek-v4-flash")
48
64
  model_id, model_provider = parse_model_string(model || DEFAULT_MODEL)
49
65
  model_for_agent = model_provider ? model_id : (model || DEFAULT_MODEL)
50
66
 
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
67
  adapter = AgentAdapter.new(
66
68
  model: model_for_agent,
67
69
  tools: tools || DEFAULT_TOOLS,
68
70
  system_prompt: system_prompt || build_default_system_prompt(workspace_path),
69
71
  agent_dir: workspace_path,
70
- hooks: hooks
72
+ approval: approval_opts[:mode],
73
+ require_approval: approval_opts[:require_approval],
74
+ plan_mode: plan_mode
71
75
  )
72
76
 
73
77
  session_id = adapter.start_session
74
78
  @store.add(session_id, adapter)
75
79
 
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
80
+ # Attach observers after registration so they see a settled store
81
+ # (e.g. the pane reporter's session-count metadata), then replay
82
+ # the session.created event the adapter buffered during start.
83
+ adapter.on_event { |event| notify_session_event(adapter.session_id, event) }
84
+ created = adapter.pending_events.find { |e| e.type == "session.created" }
85
+ notify_session_event(session_id, created) if created
81
86
 
82
- @logger.info("Created session #{session_id} (model=#{model || DEFAULT_MODEL}, permission_mode=#{permission_mode})")
87
+ @logger.info("Created session #{session_id} (model=#{model || DEFAULT_MODEL}, approval=#{approval_opts[:mode]})")
83
88
 
84
89
  session_id
85
90
  end
86
91
 
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
92
  # Remove a session.
93
93
  def destroy_session(session_id)
94
94
  adapter = @store.get(session_id)
95
95
  return false unless adapter
96
96
 
97
- # Cancel any pending permission requests
98
- handler = @permission_handlers.delete(session_id)
99
- handler&.cancel_all!
100
-
101
97
  adapter.abort_turn! if adapter.running
98
+ adapter.close!
102
99
  @store.remove(session_id)
103
100
  @logger.info("Destroyed session #{session_id}")
104
101
  true
105
102
  end
106
103
 
104
+ # Close a session (protocol alias of destroy_session).
105
+ def close_session(session_id)
106
+ destroy_session(session_id)
107
+ end
108
+
107
109
  # Get an adapter by session ID.
108
110
  def get(session_id)
109
111
  @store.get(session_id)
@@ -114,28 +116,97 @@ module Ask
114
116
  @store.list(limit: limit)
115
117
  end
116
118
 
117
- # Send a message to a session.
118
- def send_message(session_id, content)
119
+ # Send a message to a session (prompt when idle, mid-execution
120
+ # injection when running).
121
+ #
122
+ # @return [Hash] { accepted: Boolean, status: "steered"|"queued"|"stale", turnId: }
123
+ def send_message(session_id, content, expected_turn_id: nil)
119
124
  adapter = @store.get(session_id)
120
125
  raise Ask::AppServer::SessionNotFound, "Session #{session_id} not found" unless adapter
121
126
 
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
127
+ result = adapter.send_message(content, expected_turn_id: expected_turn_id)
128
+ {
129
+ accepted: result[:status] != "stale",
130
+ status: result[:status],
131
+ turnId: result[:turn_id]
132
+ }
133
+ end
128
134
 
129
- true
135
+ # ── Interactions (approvals) ───────────────────────────────────────
136
+
137
+ # Pending approval interactions for a session.
138
+ def pending_interactions(session_id)
139
+ adapter = @store.get(session_id)
140
+ raise Ask::AppServer::SessionNotFound, "Session #{session_id} not found" unless adapter
141
+
142
+ adapter.pending_interactions
143
+ end
144
+
145
+ # Approve a pending approval interaction by canonical id.
146
+ # @return [Boolean]
147
+ def approve_interaction(session_id, interaction_id)
148
+ adapter = @store.get(session_id)
149
+ raise Ask::AppServer::SessionNotFound, "Session #{session_id} not found" unless adapter
150
+
151
+ adapter.approve_interaction(interaction_id)
130
152
  end
131
153
 
154
+ # Reject a pending approval interaction by canonical id.
155
+ # @return [Boolean]
156
+ def reject_interaction(session_id, interaction_id)
157
+ adapter = @store.get(session_id)
158
+ raise Ask::AppServer::SessionNotFound, "Session #{session_id} not found" unless adapter
159
+
160
+ adapter.reject_interaction(interaction_id)
161
+ end
162
+
163
+ # Approve every pending approval interaction.
164
+ # @return [Integer] number approved
165
+ def approve_all_interactions(session_id)
166
+ adapter = @store.get(session_id)
167
+ raise Ask::AppServer::SessionNotFound, "Session #{session_id} not found" unless adapter
168
+
169
+ adapter.approve_all_interactions
170
+ end
171
+
172
+ # Reject every pending approval interaction.
173
+ # @return [Integer] number rejected
174
+ def reject_all_interactions(session_id)
175
+ adapter = @store.get(session_id)
176
+ raise Ask::AppServer::SessionNotFound, "Session #{session_id} not found" unless adapter
177
+
178
+ adapter.reject_all_interactions
179
+ end
180
+
181
+ # ── Plan mode ───────────────────────────────────────────────────────
182
+
183
+ # Approve the pending plan proposal.
184
+ # @return [Boolean]
185
+ def plan_approve(session_id)
186
+ adapter = @store.get(session_id)
187
+ raise Ask::AppServer::SessionNotFound, "Session #{session_id} not found" unless adapter
188
+
189
+ adapter.plan_approve
190
+ end
191
+
192
+ # Reject the pending plan proposal; the agent stays in plan mode.
193
+ # @return [Boolean]
194
+ def plan_reject(session_id)
195
+ adapter = @store.get(session_id)
196
+ raise Ask::AppServer::SessionNotFound, "Session #{session_id} not found" unless adapter
197
+
198
+ adapter.plan_reject
199
+ end
200
+
201
+ # ── Events and workspace ───────────────────────────────────────────
202
+
132
203
  # Subscribe to a session's events.
133
- def subscribe(session_id, delivery_kind: "web-remote-replayable")
204
+ def subscribe(session_id, delivery_kind: "replay")
134
205
  adapter = @store.get(session_id)
135
206
  raise Ask::AppServer::SessionNotFound, "Session #{session_id} not found" unless adapter
136
207
 
137
208
  @store.subscribe(session_id, delivery_kind: delivery_kind)
138
- { subscribed: true, sessionId: session_id, deliveryKind: delivery_kind }
209
+ { subscription: { sessionId: session_id, deliveryKind: delivery_kind } }
139
210
  end
140
211
 
141
212
  # Get events for a session after a sequence number.
@@ -155,6 +226,21 @@ module Ask
155
226
 
156
227
  # Read workspace state.
157
228
  def read_workspace_state(session_id = nil)
229
+ workspace = {
230
+ path: nil,
231
+ name: nil,
232
+ gitBranch: nil,
233
+ mode: approval_mode.to_s
234
+ }
235
+
236
+ if session_id
237
+ adapter = @store.get(session_id)
238
+ if adapter
239
+ workspace[:sessionId] = session_id
240
+ workspace[:running] = adapter.running
241
+ end
242
+ end
243
+
158
244
  settings = {
159
245
  model: {
160
246
  current: {
@@ -167,15 +253,7 @@ module Ask
167
253
  }
168
254
  }
169
255
 
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 }
256
+ { workspace: workspace, settings: settings }
179
257
  end
180
258
 
181
259
  # Check if a session is subscribed.
@@ -183,22 +261,37 @@ module Ask
183
261
  @store.subscribed?(session_id)
184
262
  end
185
263
 
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)
264
+ private
190
265
 
191
- adapter.drain_events
266
+ # Resolve the session mode into approval options + plan mode.
267
+ #
268
+ # @return [Array(Hash, Boolean)] [{ mode:, require_approval: }, plan_mode]
269
+ def resolve_approval(mode)
270
+ plan_mode = false
271
+ approval_mode =
272
+ case mode.to_s
273
+ when "plan" then plan_mode = true; :off
274
+ when "yolo", "never", "off" then :off
275
+ when "build", "edit", "on_request" then :require
276
+ when "auto" then :auto
277
+ else translate_permission_mode(@permission_mode)
278
+ end
279
+
280
+ opts = { mode: approval_mode }
281
+ opts[:require_approval] = @blocked_tools if approval_mode == :require
282
+ [opts, plan_mode]
192
283
  end
193
284
 
194
- private
285
+ def translate_permission_mode(mode)
286
+ case mode.to_s
287
+ when "never", "off", "yolo" then :off
288
+ when "auto" then :auto
289
+ else :require
290
+ end
291
+ end
195
292
 
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
293
+ def notify_session_event(session_id, event)
294
+ @session_event_observers.each { |observer| observer.call(session_id, event) }
202
295
  end
203
296
 
204
297
  def build_default_system_prompt(workspace_path)
@@ -217,13 +310,6 @@ module Ask
217
310
  end
218
311
  end
219
312
 
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
313
  # Parse a model string that may include a provider prefix.
228
314
  # "opencode_go/deepseek-v4-flash" => ["deepseek-v4-flash", "opencode_go"]
229
315
  # "gpt-4o" => ["gpt-4o", nil]
@@ -0,0 +1,116 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "socket"
5
+ require "fileutils"
6
+
7
+ module Ask
8
+ module AppServer
9
+ # Unix-socket transport for the session host: any number of clients can
10
+ # attach to the same sessions (terminal TUI, web console, bots) by
11
+ # connecting to the socket and speaking the canonical session protocol
12
+ # as NDJSON lines.
13
+ #
14
+ # Each accepted connection gets its own reader thread and a Connection
15
+ # with per-session delivery cursors; the shared protocol engine
16
+ # ({Server}) routes responses back to the requesting connection and
17
+ # fans out session/event notifications to every subscribed connection.
18
+ #
19
+ # The socket file is removed on start (stale cleanup) and on stop.
20
+ class SocketServer
21
+ # Default socket path: ASK_APP_SERVER_SOCKET env var or
22
+ # ~/.ask-app-server/app-server.sock
23
+ def self.default_socket_path
24
+ ENV["ASK_APP_SERVER_SOCKET"] ||
25
+ File.expand_path("~/.ask-app-server/app-server.sock")
26
+ end
27
+
28
+ attr_reader :socket_path, :protocol
29
+
30
+ # @param session_manager [SessionManager, nil] shared session manager
31
+ # @param socket_path [String, nil] unix socket path
32
+ def initialize(session_manager: nil, socket_path: nil)
33
+ @session_manager = session_manager || SessionManager.new
34
+ @protocol = Server.new(session_manager: @session_manager)
35
+ @socket_path = socket_path || self.class.default_socket_path
36
+ @running = false
37
+ @listener = nil
38
+ @acceptor = nil
39
+ @pusher = nil
40
+ @logger = Logger.new($stdout, level: ENV["DEBUG"] ? Logger::DEBUG : Logger::WARN)
41
+ end
42
+
43
+ # Bind the socket and start accepting connections. Returns
44
+ # immediately (accept and push run in background threads).
45
+ def start
46
+ @running = true
47
+ FileUtils.mkdir_p(File.dirname(@socket_path))
48
+ FileUtils.rm_f(@socket_path) # stale socket from a previous run
49
+ @listener = UNIXServer.new(@socket_path)
50
+ @acceptor = Thread.new { accept_loop }
51
+ @pusher = Thread.new { pusher_loop }
52
+ @logger.debug("Socket server listening on #{@socket_path}")
53
+ self
54
+ end
55
+
56
+ # Stop accepting, close all connections, and remove the socket file.
57
+ def stop
58
+ @running = false
59
+ @acceptor&.kill rescue nil
60
+ @pusher&.kill rescue nil
61
+ @protocol.connections.each { |conn| @protocol.remove_connection(conn) }
62
+ @listener&.close rescue nil
63
+ FileUtils.rm_f(@socket_path) rescue nil
64
+ end
65
+
66
+ # Whether the server is running.
67
+ def running?
68
+ @running
69
+ end
70
+
71
+ private
72
+
73
+ def accept_loop
74
+ while @running
75
+ socket = @listener.accept
76
+ connection = @protocol.add_connection(Connection.new(socket, socket))
77
+ reader = Thread.new { reader_loop(connection, socket) }
78
+ reader.name = "ask-socket-client" if reader.respond_to?(:name=)
79
+ end
80
+ rescue IOError, Errno::EBADF, Errno::EINVAL
81
+ # Listener closed during shutdown.
82
+ rescue => e
83
+ @logger.error("Accept error: #{e.message}")
84
+ sleep 0.1
85
+ end
86
+
87
+ def reader_loop(connection, socket)
88
+ while @running
89
+ line = connection.read_line
90
+ break unless line
91
+ line = line.strip
92
+ next if line.empty?
93
+
94
+ begin
95
+ @protocol.dispatch(JSON.parse(line), connection)
96
+ rescue JSON::ParserError => e
97
+ @protocol.send(:send_error, nil, -32700, "Parse error: #{e.message}", connection)
98
+ rescue => e
99
+ @logger.error("Socket reader error: #{e.message}")
100
+ break
101
+ end
102
+ end
103
+ ensure
104
+ @protocol.remove_connection(connection)
105
+ socket.close rescue nil
106
+ end
107
+
108
+ def pusher_loop
109
+ while @running
110
+ @protocol.push_pending
111
+ sleep 0.1 # 100ms poll interval
112
+ end
113
+ end
114
+ end
115
+ end
116
+ end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Ask
4
4
  module AppServer
5
- VERSION = "0.1.2"
5
+ VERSION = "0.4.2"
6
6
  end
7
7
  end
@@ -7,6 +7,8 @@ module Ask
7
7
  class SessionNotFound < Error; end
8
8
  class SessionAlreadyExists < Error; end
9
9
  class SessionNotSubscribed < Error; end
10
+ class InteractionNotFound < Error; end
11
+ class PlanNotFound < Error; end
10
12
  class InvalidRequest < Error; end
11
13
  class TimeoutError < Error; end
12
14
  end
@@ -1,15 +1,19 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "ask/agent"
4
+ require "ask-session-protocol"
4
5
  require "ask-tools-shell"
5
6
 
6
7
  require_relative "ask/app_server/version"
7
8
  require_relative "ask/app_server"
8
9
  require_relative "ask/app_server/config"
9
10
  require_relative "ask/app_server/event_translator"
11
+ require_relative "ask/app_server/connection"
10
12
  require_relative "ask/app_server/agent_adapter"
11
13
  require_relative "ask/app_server/permission_handler"
12
14
  require_relative "ask/app_server/session_store"
13
15
  require_relative "ask/app_server/session_manager"
14
16
  require_relative "ask/app_server/server"
17
+ require_relative "ask/app_server/socket_server"
18
+ require_relative "ask/app_server/herdr_reporter"
15
19
  require_relative "ask/app_server/cli"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ask-app-server
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.2
4
+ version: 0.4.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Kaka Ruto
@@ -15,14 +15,14 @@ dependencies:
15
15
  requirements:
16
16
  - - ">="
17
17
  - !ruby/object:Gem::Version
18
- version: '0.1'
18
+ version: 0.40.1
19
19
  type: :runtime
20
20
  prerelease: false
21
21
  version_requirements: !ruby/object:Gem::Requirement
22
22
  requirements:
23
23
  - - ">="
24
24
  - !ruby/object:Gem::Version
25
- version: '0.1'
25
+ version: 0.40.1
26
26
  - !ruby/object:Gem::Dependency
27
27
  name: ask-core
28
28
  requirement: !ruby/object:Gem::Requirement
@@ -38,7 +38,7 @@ dependencies:
38
38
  - !ruby/object:Gem::Version
39
39
  version: '0.1'
40
40
  - !ruby/object:Gem::Dependency
41
- name: ask-tools
41
+ name: ask-session-protocol
42
42
  requirement: !ruby/object:Gem::Requirement
43
43
  requirements:
44
44
  - - ">="
@@ -51,6 +51,20 @@ dependencies:
51
51
  - - ">="
52
52
  - !ruby/object:Gem::Version
53
53
  version: '0.1'
54
+ - !ruby/object:Gem::Dependency
55
+ name: ask-tools
56
+ requirement: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - ">="
59
+ - !ruby/object:Gem::Version
60
+ version: 0.6.1
61
+ type: :runtime
62
+ prerelease: false
63
+ version_requirements: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - ">="
66
+ - !ruby/object:Gem::Version
67
+ version: 0.6.1
54
68
  - !ruby/object:Gem::Dependency
55
69
  name: ask-tools-shell
56
70
  requirement: !ruby/object:Gem::Requirement
@@ -168,11 +182,14 @@ files:
168
182
  - lib/ask/app_server/agent_adapter.rb
169
183
  - lib/ask/app_server/cli.rb
170
184
  - lib/ask/app_server/config.rb
185
+ - lib/ask/app_server/connection.rb
171
186
  - lib/ask/app_server/event_translator.rb
187
+ - lib/ask/app_server/herdr_reporter.rb
172
188
  - lib/ask/app_server/permission_handler.rb
173
189
  - lib/ask/app_server/server.rb
174
190
  - lib/ask/app_server/session_manager.rb
175
191
  - lib/ask/app_server/session_store.rb
192
+ - lib/ask/app_server/socket_server.rb
176
193
  - lib/ask/app_server/version.rb
177
194
  homepage: https://github.com/ask-rb/ask-app-server
178
195
  licenses:
@@ -195,7 +212,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
195
212
  - !ruby/object:Gem::Version
196
213
  version: '0'
197
214
  requirements: []
198
- rubygems_version: 4.0.3
215
+ rubygems_version: 4.0.18
199
216
  specification_version: 4
200
217
  summary: JSON-RPC app-server for ask-rb agents
201
218
  test_files: []