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.
@@ -8,14 +8,16 @@ module Ask
8
8
  #
9
9
  # Usage:
10
10
  # ask-app-server # Start in stdio mode (default)
11
- # ask-app-server --version # Show version
12
- # ask-app-server --help # Show help
13
- # ask-app-server --config PATH # Use specific config file
11
+ # ask-app-server --socket PATH # Also listen on a unix socket (multi-client)
12
+ # ask-app-server --version # Show version
13
+ # ask-app-server --help # Show help
14
+ # ask-app-server --config PATH # Use specific config file
14
15
  #
15
16
  # Environment variables:
16
17
  # ASK_APP_SERVER_CONFIG - Path to config file (default: auto-detect)
17
18
  # ASK_APP_SERVER_MODEL - Model to use (overrides config file)
18
19
  # ASK_APP_SERVER_PERMISSIONS - Permission mode (overrides config file)
20
+ # ASK_APP_SERVER_SOCKET - Unix socket path (same as --socket)
19
21
  # DEBUG - Enable debug logging (1/0)
20
22
  class CLI
21
23
  def self.run!(args = ARGV)
@@ -32,14 +34,14 @@ module Ask
32
34
  return
33
35
  end
34
36
 
35
- # Parse --config from args
37
+ # Parse --config and --socket from args
36
38
  config_path = nil
37
- remaining_args = []
39
+ socket_path = nil
38
40
  args.each_with_index do |arg, i|
39
41
  if arg == "--config" && i + 1 < args.length
40
42
  config_path = args[i + 1]
41
- elsif !arg.start_with?("--config")
42
- remaining_args << arg
43
+ elsif arg == "--socket" && i + 1 < args.length
44
+ socket_path = args[i + 1]
43
45
  end
44
46
  end
45
47
 
@@ -66,21 +68,34 @@ module Ask
66
68
  permission_timeout: config.permission_timeout
67
69
  )
68
70
 
69
- # Start the server
70
- server = Server.new(session_manager: session_manager)
71
-
72
- # Wire the server as the protocol sender for permission requests.
73
- # Every time a new session is created with a PermissionHandler,
74
- # the server registers its outgoing request callback.
75
- session_manager.on_new_permission_handler do |handler|
76
- server.register_permission_handler(handler)
71
+ # Pane integration: when running inside a terminal workspace,
72
+ # keep its agent-state sidebar accurate from first-party host
73
+ # events.
74
+ herdr_reporter = HerdrReporter.attach(session_manager)
75
+
76
+ # Optional unix-socket transport for multi-client attach (runs
77
+ # alongside the stdio transport, sharing the session manager).
78
+ socket_path ||= ENV["ASK_APP_SERVER_SOCKET"]
79
+ socket_server = nil
80
+ if socket_path
81
+ socket_server = SocketServer.new(session_manager: session_manager, socket_path: socket_path)
82
+ socket_server.start
83
+ $stderr.puts "socket: #{socket_server.socket_path}"
77
84
  end
78
85
 
86
+ # Start the server (stdio transport, blocks)
87
+ server = Server.new(session_manager: session_manager)
88
+
79
89
  begin
80
90
  server.start
81
91
  rescue Interrupt
82
92
  $stderr.puts "\n[ask-app-server] Shutting down..." if config.debug?
93
+ ensure
94
+ # Clean up on Ctrl-C AND on normal exit (stdin EOF): the socket
95
+ # file must not outlive the host.
83
96
  server.stop
97
+ socket_server&.stop
98
+ herdr_reporter&.close
84
99
  end
85
100
  end
86
101
 
@@ -115,7 +130,7 @@ module Ask
115
130
  ask-app-server v#{Ask::AppServer::VERSION}
116
131
 
117
132
  JSON-RPC/stdio app-server for ask-rb agents.
118
- Drop-in compatible with the ZCode/Codex app-server protocol.
133
+ Drop-in compatible with the app-server protocol.
119
134
 
120
135
  USAGE:
121
136
  ask-app-server Start in stdio mode
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Ask
6
+ module AppServer
7
+ # A client connection to the session host: an input/output pair (stdio
8
+ # or a unix socket) plus the connection's per-session event delivery
9
+ # cursors.
10
+ #
11
+ # Delivery is cursor-based: the connection tracks the last delivered
12
+ # seq for each subscribed session, so any number of clients can attach
13
+ # to the same sessions and each receives exactly the events after its
14
+ # own cursor. Subscribe with afterSeq to replay; clients dedup by seq.
15
+ class Connection
16
+ # session_id => last delivered seq
17
+ attr_reader :subscriptions
18
+
19
+ # @param input [IO] source of NDJSON lines (responds to gets)
20
+ # @param output [IO] sink for NDJSON responses/notifications
21
+ def initialize(input, output)
22
+ @input = input
23
+ @output = output
24
+ @subscriptions = {}
25
+ @mutex = Mutex.new
26
+ end
27
+
28
+ # Read one line from the input, or nil on EOF/disconnect.
29
+ def read_line
30
+ @input.gets
31
+ end
32
+
33
+ # Write one JSON-RPC message (Hash) to the output.
34
+ def write(msg)
35
+ @output.puts(JSON.generate(msg))
36
+ @output.flush
37
+ end
38
+
39
+ # Whether the connection is subscribed to a session.
40
+ def subscribed?(session_id)
41
+ @mutex.synchronize { @subscriptions.key?(session_id) }
42
+ end
43
+
44
+ # Subscribe to a session, starting delivery after after_seq.
45
+ def subscribe(session_id, after_seq: 0)
46
+ @mutex.synchronize { @subscriptions[session_id] = after_seq.to_i }
47
+ end
48
+
49
+ # Unsubscribe from a session.
50
+ def unsubscribe(session_id)
51
+ @mutex.synchronize { @subscriptions.delete(session_id) }
52
+ end
53
+
54
+ # The last delivered seq for a session (0 = replay from the start).
55
+ def cursor(session_id)
56
+ @mutex.synchronize { @subscriptions[session_id] || 0 }
57
+ end
58
+
59
+ # Advance the delivery cursor for a session (only while subscribed).
60
+ def advance(session_id, seq)
61
+ @mutex.synchronize do
62
+ @subscriptions[session_id] = seq if @subscriptions.key?(session_id)
63
+ end
64
+ end
65
+
66
+ # Drop all subscriptions. Does not close the underlying I/O — the
67
+ # owner (the server) manages IO lifecycle.
68
+ def close
69
+ @mutex.synchronize { @subscriptions.clear }
70
+ end
71
+ end
72
+ end
73
+ end
@@ -4,89 +4,124 @@ require "securerandom"
4
4
 
5
5
  module Ask
6
6
  module AppServer
7
- # Translates ask-agent Events into app-server protocol events.
7
+ # Translates ask-agent runtime events into canonical
8
+ # Ask::SessionProtocol events (the contract between the host and every
9
+ # client). Every emitted event is validated against the protocol
10
+ # registry, so a malformed translation fails fast at the boundary.
8
11
  #
9
- # ask-agent emits events like TurnStart, TextDelta, ToolExecutionStart, etc.
10
- # The app-server protocol uses a different set: turn.started, model.streaming,
11
- # tool.updated, turn.completed, turn.failed, message.upserted.
12
- #
13
- # This class maps between the two models, emitting app-server protocol events
14
- # that clients such as the Python Telegram bot and Vercel AI SDK expect.
12
+ # The translator owns the per-session event buffer and sequence
13
+ # numbers: clients poll (`session/events` after seq N) or subscribe
14
+ # (the server pushes drained events as `session/event` notifications).
15
15
  class EventTranslator
16
- attr_reader :session_id, :turn_id
17
-
18
- def initialize(session_id)
19
- @session_id = session_id
20
- @turn_id = nil
21
- @seq = 0
16
+ # Events retained per session for replay/polling. Cursor-based
17
+ # delivery means the log is append-only; clients with cursors older
18
+ # than the cap miss the dropped events (durable log is a host
19
+ # storage concern).
20
+ MAX_EVENTS = 2000
21
+
22
+ # Optional observer called with every emitted canonical Event.
23
+ # This is the single choke point for all session events (translations
24
+ # plus approval/plan/session-lifecycle emissions), so host-side
25
+ # side effects (e.g. the pane reporter) hook here.
26
+ attr_accessor :on_event
27
+
28
+ def initialize
22
29
  @events = []
23
- @streaming_text = +""
30
+ @seq = 0
31
+ @turn_id = nil
24
32
  @turn_active = false
25
- @in_reflection = false
33
+ @streaming_text = +""
34
+ @plan_interaction_id = nil
26
35
  end
27
36
 
28
- # Translate an ask-agent event into zero or more app-server events.
29
- # Returns an array of event hashes (may be empty).
37
+ # Translate one ask-agent event into zero or more canonical events.
38
+ # Returns an array of Ask::SessionProtocol::Events::Event (may be
39
+ # empty); events are buffered for delivery.
30
40
  def translate(agent_event)
31
41
  case agent_event
32
42
  when Ask::Agent::Events::TurnStart
33
- translate_turn_start
43
+ turn_started
34
44
  when Ask::Agent::Events::TextDelta
35
- translate_text_delta(agent_event)
45
+ text_delta(agent_event)
46
+ when Ask::Agent::Events::ThinkingDelta
47
+ thinking_delta(agent_event)
36
48
  when Ask::Agent::Events::ToolCallDelta
37
- # ToolCallDelta is informational; we track calls but don't emit
38
- # until execution actually starts.
49
+ # Informational; execution events carry the tool lifecycle.
39
50
  []
40
51
  when Ask::Agent::Events::ToolExecutionStart
41
- translate_tool_start(agent_event)
52
+ tool_start(agent_event)
42
53
  when Ask::Agent::Events::ToolExecutionUpdate
43
- translate_tool_update(agent_event)
54
+ tool_update(agent_event)
44
55
  when Ask::Agent::Events::ToolExecutionEnd
45
- translate_tool_end(agent_event)
46
- when Ask::Agent::Events::MessageEnd
47
- # Fires after the LLM response is complete (before tool execution).
48
- # Not mapped directly; we already streamed the text.
49
- []
50
- when Ask::Agent::Events::TurnEnd
51
- # Fires after tool execution completes for one recursive iteration.
52
- # The session may continue with more tool calls.
53
- []
54
- when Ask::Agent::Events::ReflectionStart
55
- # Reflection is an internal detail — skip
56
- @in_reflection = true
57
- []
58
- when Ask::Agent::Events::ReflectionDelta
59
- # Treat reflection text as regular model output
60
- translate_text_delta(agent_event)
61
- when Ask::Agent::Events::ReflectionEnd
62
- @in_reflection = false
63
- []
64
- when Ask::Agent::Events::CompactionStart
65
- []
66
- when Ask::Agent::Events::CompactionEnd
67
- []
68
- when Ask::Agent::Events::Error
69
- translate_error(agent_event)
70
- when Ask::Agent::Events::SessionEnd
71
- translate_session_end(agent_event)
56
+ tool_end(agent_event)
57
+ when Ask::Agent::Events::TodoUpdated
58
+ todos_updated(agent_event)
59
+ when Ask::Agent::Events::PlanProposed
60
+ plan_proposed(agent_event)
61
+ when Ask::Agent::Events::PlanApproved
62
+ plan_approved(agent_event)
63
+ when Ask::Agent::Events::PlanRejected
64
+ plan_rejected(agent_event)
72
65
  when Ask::Agent::Events::MaxTurnsExceeded
73
- translate_turn_failed("Max turns exceeded (#{agent_event.max_turns})")
66
+ turn_failed("Max turns exceeded (#{agent_event.max_turns})")
74
67
  when Ask::Agent::Events::LoopDetected
75
- translate_turn_failed("Loop detected on tool: #{agent_event.tool_name}")
68
+ turn_failed("Loop detected on tool: #{agent_event.tool_name}")
69
+ when Ask::Agent::Events::Error
70
+ error(agent_event)
71
+ when Ask::Agent::Events::SessionEnd
72
+ session_end(agent_event)
76
73
  else
74
+ # TurnEnd, MessageStart/End, Reflection*, Compaction*,
75
+ # SessionRolledBack/Forked, Evaluation*, MetaAgentAnalysis —
76
+ # internal detail, no canonical representation.
77
77
  []
78
78
  end
79
79
  end
80
80
 
81
- # All events emitted since last poll.
81
+ # ── Queue/plan-driven emission (not ask-agent events) ──────────────
82
+
83
+ # An approval action was queued: emit approval.required.
84
+ #
85
+ # @param action [Ask::Agent::ApprovalQueue::Action]
86
+ def approval_required(action)
87
+ payload = { "toolName" => action.tool_name.to_s }
88
+ payload["args"] = action.args if action.args
89
+ payload["message"] = action.message if action.message
90
+ payload["autoApprovable"] = action.auto_approvable unless action.auto_approvable.nil?
91
+ emit("approval.required", payload.merge("id" => "act_#{action.id}"))
92
+ end
93
+
94
+ # An approval action changed status: emit approval.updated.
95
+ #
96
+ # @param action [Ask::Agent::ApprovalQueue::Action]
97
+ def approval_updated(action)
98
+ status = action.status.to_s
99
+ return unless %w[approved rejected].include?(status)
100
+
101
+ emit("approval.updated", { "id" => "act_#{action.id}", "status" => status })
102
+ end
103
+
104
+ # A session was created: emit session.created.
105
+ def session_created(session_id)
106
+ emit("session.created", { "sessionId" => session_id })
107
+ end
108
+
109
+ # A session ended: emit session.ended.
110
+ def session_ended(session_id, reason: "closed")
111
+ emit("session.ended", { "sessionId" => session_id, "reason" => reason })
112
+ end
113
+
114
+ # ── Buffer access ──────────────────────────────────────────────────
115
+
116
+ # All events since the last drain.
82
117
  def pending_events
83
118
  @events
84
119
  end
85
120
 
86
121
  # Drain and return all pending events, clearing the buffer.
87
122
  def drain_events
88
- evs = @events.dup
89
- @events.clear
123
+ evs = @events
124
+ @events = []
90
125
  evs
91
126
  end
92
127
 
@@ -95,99 +130,120 @@ module Ask
95
130
  @seq
96
131
  end
97
132
 
133
+ # Events after a given sequence number.
134
+ def events_after(after_seq)
135
+ @events.select { |e| e.seq > after_seq }
136
+ end
137
+
98
138
  private
99
139
 
100
140
  def next_seq
101
141
  @seq += 1
102
- @seq
103
142
  end
104
143
 
105
- def translate_turn_start
144
+ def turn_started
106
145
  @turn_id = SecureRandom.uuid
107
146
  @turn_active = true
108
147
  @streaming_text = +""
109
-
110
- ev = build_event("turn.started", { turnId: @turn_id })
111
- [ev]
148
+ emit("turn.started", { "turnId" => @turn_id })
112
149
  end
113
150
 
114
- def translate_text_delta(event)
151
+ def text_delta(event)
115
152
  content = event.content.to_s
116
153
  return [] if content.empty?
117
154
 
118
155
  @streaming_text << content
156
+ emit("model.streaming", { "delta" => content })
157
+ end
158
+
159
+ def thinking_delta(event)
160
+ content = event.content.to_s
161
+ return [] if content.empty?
162
+
163
+ emit("model.thinking", { "delta" => content })
164
+ end
119
165
 
120
- ev = build_event("model.streaming", { delta: content })
121
- [ev]
166
+ def tool_start(event)
167
+ payload = {
168
+ "id" => event.id.to_s,
169
+ "name" => event.name.to_s,
170
+ "args" => event.arguments
171
+ }
172
+ emit("tool.use", payload)
122
173
  end
123
174
 
124
- def translate_tool_start(event)
125
- ev = build_event("tool.updated", {
126
- toolName: event.name,
127
- kind: "started",
128
- input: event.arguments
129
- })
130
- [ev]
175
+ def tool_update(event)
176
+ payload = {
177
+ "id" => event.id.to_s,
178
+ "name" => event.name.to_s,
179
+ "partial" => event.partial_result.to_s
180
+ }
181
+ emit("tool.delta", payload)
131
182
  end
132
183
 
133
- def translate_tool_update(event)
134
- ev = build_event("tool.updated", {
135
- toolName: event.name,
136
- kind: "updated",
137
- output: event.partial_result.to_s
138
- })
139
- [ev]
184
+ def tool_end(event)
185
+ payload = {
186
+ "id" => event.id.to_s,
187
+ "name" => event.name.to_s,
188
+ "output" => event.result,
189
+ "isError" => !!event.is_error,
190
+ "durationMs" => event.duration_ms
191
+ }
192
+ emit("tool.result", payload)
140
193
  end
141
194
 
142
- def translate_tool_end(event)
143
- kind = event.is_error ? "failed" : "completed"
144
- ev = build_event("tool.updated", {
145
- toolName: event.name,
146
- kind: kind,
147
- output: event.result.to_s,
148
- durationMs: event.duration_ms
149
- })
150
- [ev]
195
+ def todos_updated(event)
196
+ todos = event.todos.map { |entry| entry.to_h.transform_keys(&:to_s) }
197
+ emit("todos.updated", { "todos" => todos })
151
198
  end
152
199
 
153
- def translate_session_end(event)
154
- @turn_active = false
155
- response_text = event.result.to_s
200
+ def plan_proposed(event)
201
+ @plan_interaction_id = "plan_#{next_seq}"
202
+ emit("plan.proposed", { "id" => @plan_interaction_id, "plan" => event.plan.to_s })
203
+ end
156
204
 
157
- ev = build_event("turn.completed", {
158
- response: response_text,
159
- turnCount: event.turn_count,
160
- toolCallsMade: event.tool_calls_made,
161
- inputTokens: event.input_tokens,
162
- outputTokens: event.output_tokens,
163
- cost: event.cost
164
- })
165
- [ev]
205
+ def plan_approved(event)
206
+ payload = { "plan" => event.plan.to_s }
207
+ payload["id"] = @plan_interaction_id if @plan_interaction_id
208
+ emit("plan.approved", payload)
166
209
  end
167
210
 
168
- def translate_error(event)
169
- return [] if @turn_active
170
- translate_turn_failed(event.error)
211
+ def plan_rejected(event)
212
+ payload = { "plan" => event.plan.to_s }
213
+ payload["id"] = @plan_interaction_id if @plan_interaction_id
214
+ emit("plan.rejected", payload)
171
215
  end
172
216
 
173
- def translate_turn_failed(message)
217
+ def session_end(event)
174
218
  @turn_active = false
175
- ev = build_event("turn.failed", {
176
- error: { message: message.to_s }
177
- })
178
- [ev]
179
- end
180
-
181
- def build_event(type, payload)
182
- event = {
183
- type: type,
184
- seq: next_seq,
185
- payload: payload,
186
- turnId: @turn_id,
187
- sessionId: @session_id
188
- }
219
+ payload = {}
220
+ payload["turnId"] = @turn_id if @turn_id
221
+ payload["response"] = event.result.to_s
222
+ payload["inputTokens"] = event.input_tokens if event.input_tokens
223
+ payload["outputTokens"] = event.output_tokens if event.output_tokens
224
+ payload["cost"] = event.cost if event.cost
225
+ emit("turn.completed", payload)
226
+ end
227
+
228
+ def error(event)
229
+ payload = { "error" => event.error.to_s }
230
+ payload["recoverable"] = event.recoverable unless event.recoverable.nil?
231
+ emit("error", payload)
232
+ end
233
+
234
+ def turn_failed(message)
235
+ @turn_active = false
236
+ payload = { "error" => message.to_s }
237
+ payload["turnId"] = @turn_id if @turn_id
238
+ emit("turn.failed", payload)
239
+ end
240
+
241
+ def emit(type, payload)
242
+ event = Ask::SessionProtocol::Events.event(type: type, seq: next_seq, payload: payload)
189
243
  @events << event
190
- event
244
+ @events.shift if @events.size > MAX_EVENTS
245
+ on_event&.call(event)
246
+ [event]
191
247
  end
192
248
  end
193
249
  end