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.
@@ -0,0 +1,201 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "socket"
5
+
6
+ module Ask
7
+ module AppServer
8
+ # Pane integration: when the host runs inside a terminal workspace
9
+ # that injects HERDR_SOCKET_PATH and HERDR_PANE_ID into the pane's
10
+ # environment, this reporter connects to the workspace socket and
11
+ # keeps its agent-state sidebar accurate with first-party state —
12
+ # the host KNOWS when a turn is running or an approval is pending,
13
+ # which beats screen scraping:
14
+ #
15
+ # pane.report_agent — working | blocked | idle, on change
16
+ # pane.report_agent_session — the ask session id, for future resume
17
+ # pane.report_metadata — model and session count as sidebar tokens
18
+ #
19
+ # The pane state is the aggregate across the host's sessions (a host
20
+ # can serve many clients): blocked if any session awaits an approval,
21
+ # working if any turn is running, idle otherwise. Events that cannot
22
+ # change the aggregate (model.streaming, tool.*, todos) are ignored.
23
+ #
24
+ # Attach once per session manager: HerdrReporter.attach(manager).
25
+ class HerdrReporter
26
+ SOCKET_PATH_ENV = "HERDR_SOCKET_PATH"
27
+ PANE_ID_ENV = "HERDR_PANE_ID"
28
+
29
+ # Reports originate from the ask host; the workspace uses the pair
30
+ # to dedup and order per-source reports.
31
+ SOURCE = "ask:app-server"
32
+ AGENT = "ask"
33
+
34
+ # Events that can change the aggregate pane state.
35
+ STATE_EVENTS = %w[
36
+ turn.started turn.completed turn.failed turn.aborted
37
+ approval.required approval.updated
38
+ plan.proposed plan.approved plan.rejected
39
+ session.created session.ended
40
+ ].freeze
41
+
42
+ # @return [HerdrReporter, nil] a live reporter, or nil when the
43
+ # workspace environment variables are absent
44
+ def self.attach(session_manager, socket_path: ENV[SOCKET_PATH_ENV], pane_id: ENV[PANE_ID_ENV])
45
+ return nil if socket_path.to_s.empty? || pane_id.to_s.empty?
46
+
47
+ new(session_manager: session_manager, socket_path: socket_path, pane_id: pane_id)
48
+ end
49
+
50
+ # @param session_manager [SessionManager]
51
+ # @param socket_path [String] the workspace socket (HERDR_SOCKET_PATH)
52
+ # @param pane_id [String] this pane (HERDR_PANE_ID)
53
+ def initialize(session_manager:, socket_path:, pane_id:)
54
+ @session_manager = session_manager
55
+ @socket_path = socket_path
56
+ @pane_id = pane_id
57
+ @seq = 0
58
+ @reported_state = nil
59
+ @socket = nil
60
+ @mutex = Mutex.new
61
+ @enabled = true
62
+
63
+ session_manager.on_session_event { |session_id, event| on_event(session_id, event) }
64
+ report_state(compute_state)
65
+ end
66
+
67
+ # Stop reporting and drop the workspace connection.
68
+ def close
69
+ @enabled = false
70
+ @mutex.synchronize do
71
+ @socket&.close rescue nil
72
+ @socket = nil
73
+ end
74
+ end
75
+
76
+ private
77
+
78
+ def on_event(session_id, event)
79
+ return unless @enabled
80
+
81
+ case event.type
82
+ when "session.created"
83
+ report_session(session_id)
84
+ report_metadata
85
+ end
86
+ return unless STATE_EVENTS.include?(event.type)
87
+
88
+ report_state(compute_state)
89
+ end
90
+
91
+ # Aggregate pane state across all sessions: blocked wins over
92
+ # working wins over idle.
93
+ def compute_state
94
+ blocked = false
95
+ working = false
96
+ @session_manager.store.each do |adapter|
97
+ next unless adapter.session_id
98
+
99
+ if adapter.pending_interactions.any?
100
+ blocked = true
101
+ elsif adapter.running
102
+ working = true
103
+ end
104
+ end
105
+ blocked ? "blocked" : working ? "working" : "idle"
106
+ end
107
+
108
+ def report_state(state)
109
+ return if state == @reported_state
110
+
111
+ @reported_state = state
112
+ params = {
113
+ pane_id: @pane_id,
114
+ source: SOURCE,
115
+ agent: AGENT,
116
+ state: state,
117
+ seq: next_seq
118
+ }
119
+ params["message"] = "awaiting approval" if state == "blocked"
120
+ send_request("pane.report_agent", params)
121
+ end
122
+
123
+ def report_session(session_id)
124
+ send_request("pane.report_agent_session", {
125
+ pane_id: @pane_id,
126
+ source: SOURCE,
127
+ agent: AGENT,
128
+ agent_session_id: session_id,
129
+ seq: next_seq
130
+ })
131
+ end
132
+
133
+ def report_metadata
134
+ send_request("pane.report_metadata", {
135
+ pane_id: @pane_id,
136
+ source: SOURCE,
137
+ agent: AGENT,
138
+ tokens: {
139
+ "model" => current_model,
140
+ "sessions" => @session_manager.store.count.to_s
141
+ },
142
+ seq: next_seq
143
+ })
144
+ end
145
+
146
+ def current_model
147
+ model = nil
148
+ @session_manager.store.each do |adapter|
149
+ model = adapter.instance_variable_get(:@model)
150
+ break if model
151
+ end
152
+ model || ENV.fetch("ASK_APP_SERVER_MODEL", "unknown")
153
+ end
154
+
155
+ def next_seq
156
+ @mutex.synchronize do
157
+ @seq += 1
158
+ end
159
+ end
160
+
161
+ def send_request(method, params)
162
+ socket = ensure_socket
163
+ return unless socket
164
+
165
+ socket.puts(JSON.generate({ id: "#{SOURCE}:#{@seq}", method: method, params: params }))
166
+ socket.flush
167
+ rescue Errno::EPIPE, Errno::ECONNREFUSED, IOError
168
+ # The workspace went away; the next report reconnects
169
+ @socket = nil
170
+ end
171
+
172
+ def ensure_socket
173
+ @mutex.synchronize do
174
+ return @socket if @socket && !@socket.closed?
175
+
176
+ begin
177
+ @socket = UNIXSocket.new(@socket_path)
178
+ @drainer = Thread.new { drain_loop(@socket) }
179
+ rescue Errno::ENOENT, Errno::ECONNREFUSED
180
+ @socket = nil
181
+ end
182
+ @socket
183
+ end
184
+ end
185
+
186
+ # The workspace answers every request; drain responses so the
187
+ # socket never fills. Dies with the socket; the next report
188
+ # reconnects.
189
+ def drain_loop(socket)
190
+ while (line = socket.gets)
191
+ # workspace responses/notifications — nothing to do
192
+ end
193
+ rescue IOError, SystemCallError
194
+ # socket closed
195
+ ensure
196
+ socket.close rescue nil
197
+ @mutex.synchronize { @socket = nil if @socket == socket }
198
+ end
199
+ end
200
+ end
201
+ end