ask-app-server 0.1.2 → 0.4.1

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 147a968eaddfd9ebce8ef4d0f04cb64c34e76a95e859a676ff79c75b270f6931
4
- data.tar.gz: ce70ee81dfe2a5a0dcb4d1bd922b97d90f6aae5675c764df11bf99d1a585c7ae
3
+ metadata.gz: 58a83cf6f150e43e40a2e188ca05c8d196fc5225c1439f87d1797383836ce764
4
+ data.tar.gz: 41efacd1430104a977fbd05f334fb89f4e8477e23d12cad30f3601dca505ae86
5
5
  SHA512:
6
- metadata.gz: 68a1c0af900543c31aedc96625c1df396507c44cc584267ad27e8ec9253d31029a79139537f3db0967b48ef6fee6cc33ace6ab1723b4b5741f6b436f40bdea0d
7
- data.tar.gz: a4e0fa7e7a3004dbe8a7ca935254cb879d6a8125ee4e480520a28d5397653ed71fe40f896eacd3b0b98429bea3e8af88727684da590def6eb7d0d3b313aeb36d
6
+ metadata.gz: 250a42b9bfe5a2c4491083a643886fe41931b00c723640ddc56658b4d02880c2000c74e7cc1fce52cdf0e2bc0373ccd70fc4a6c288e2699ecbfc9c78c38402f8
7
+ data.tar.gz: 3d7918f44bc5cd5610faf1be139c3f23e6b739dd87b7d7fe35afe14d9c296deb877220896621dbb8f441f9741623b0178288f09f5ae1c0e8e7fb0c1b4aaf165b
data/CHANGELOG.md CHANGED
@@ -1,5 +1,104 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.4.0] - 2026-08-11
4
+
5
+ ### Added
6
+
7
+ - **Herdr citizenship** — `HerdrReporter` keeps a herdr sidebar accurate
8
+ with first-party state when the host runs inside a herdr pane. Reads
9
+ `HERDR_SOCKET_PATH`/`HERDR_PANE_ID` (injected into every pane), reports
10
+ the aggregate pane state (`working` | `blocked` | `idle`) via
11
+ `pane.report_agent` on change, the ask session id via
12
+ `pane.report_agent_session` (for future resume), and model/session
13
+ tokens via `pane.report_metadata`. `HerdrReporter.attach(session_manager)`
14
+ returns nil outside herdr; the CLI attaches automatically.
15
+ - **Session event observers** — `SessionManager#on_session_event` receives
16
+ every canonical event across all sessions as it happens; the observer
17
+ chain hooks the single emission point (`EventTranslator#on_event` →
18
+ `AgentAdapter#on_event`). Observers attach after store registration so
19
+ the store is settled, and the buffered `session.created` is replayed.
20
+
21
+ ## [0.3.0] - 2026-08-11
22
+
23
+ ### Added
24
+
25
+ - **Unix-socket transport for multi-client attach** — `SocketServer`
26
+ (`ask-app-server --socket PATH` or `ASK_APP_SERVER_SOCKET`). Any number
27
+ of clients (terminal TUI, web console, bots) connect to the same host
28
+ and share sessions; each connection has its own reader thread, and
29
+ responses are routed back to the requesting connection. The stdio
30
+ transport runs alongside.
31
+ - **Per-connection event delivery cursors** — the host no longer drains a
32
+ shared buffer. Each `Connection` tracks the last delivered seq per
33
+ subscribed session; `session/subscribe` with `afterSeq` replays exactly
34
+ what the client hasn't seen, and each client receives each event
35
+ exactly once (dedup by seq). `session/event` notifications fan out to
36
+ every subscribed connection.
37
+ - **Host-side contract enforcement** — incoming requests on the canonical
38
+ surface are validated against `Ask::SessionProtocol::Methods` before
39
+ dispatch (invalid params → `-32602 invalid_params`). Params are
40
+ normalized to string keys so Ruby clients may send symbols.
41
+ - **`Connection` class** — per-client I/O plus subscription cursors;
42
+ `EventTranslator` retains a capped event log (2000) for replay/polling.
43
+
44
+ ### Changed
45
+
46
+ - `Server` is now the transport-agnostic protocol engine: `dispatch(msg,
47
+ connection)` processes one message and writes responses back to the
48
+ sending connection; `push_pending` delivers events to subscribed
49
+ connections. `Server#start` is the stdio transport (one connection).
50
+ - `session/subscribe` now registers the *connection's* cursor (replay
51
+ default) instead of a global store flag.
52
+
53
+ ### Removed
54
+
55
+ - `SessionManager#pending_notifications` (superseded by cursor-based
56
+ delivery).
57
+
58
+ ## [0.2.0] - 2026-08-11
59
+
60
+ ### Changed
61
+
62
+ - **The host now speaks the canonical Ask::SessionProtocol** (ask-session-protocol
63
+ gem). The event vocabulary is the canonical contract — `turn.started`,
64
+ `model.streaming`, `model.thinking`, `tool.use/delta/result`,
65
+ `approval.required/updated`, `plan.proposed/approved/rejected`,
66
+ `todos.updated`, `session.created/ended`, `turn.completed/failed/aborted`,
67
+ `error` — in the validated `{type, seq, payload}` envelope with string-keyed
68
+ payloads. The old app-server vocabulary (`tool.updated`, `message.upserted`,
69
+ symbol-keyed payloads) is gone; `initialize` negotiates
70
+ `Ask::SessionProtocol::PROTOCOL_VERSION` and advertises capabilities.
71
+ - **Approvals are canonical resolvable interactions.** The blocking
72
+ PermissionHandler flow is replaced by the ask-agent approval queue: gated
73
+ tools pause the turn, `approval.required` streams to every client, and any
74
+ client resolves by id via `interaction/approve`/`interaction/reject` (or
75
+ approve/reject-all). `interaction/list` reports pending approvals.
76
+ - **Steer-based mid-execution injection.** `session/send` returns
77
+ `{accepted, status, turnId}` with `steered | queued | stale` semantics:
78
+ idle sessions run the prompt, running sessions queue it for the next turn
79
+ boundary (no abort), and `expectedTurnId` guards staleness. Queued steers
80
+ drain automatically when the running turn completes.
81
+ - **New methods** — `session/close`, `interaction/list|approve|reject|
82
+ approve-all|reject-all`, `plan/approve|reject`. `interaction/respond`
83
+ returns `-32009 not_implemented` until elicitation exists in the runtime.
84
+ - `workspace/readState` returns the canonical `{workspace: {path, name,
85
+ gitBranch, mode}}` shape (legacy `settings` retained for compatibility).
86
+ - `session/subscribe` returns `{subscription: {sessionId, deliveryKind}}`
87
+ and can include a replay snapshot.
88
+
89
+ ### Added
90
+
91
+ - `session.created` / `session.ended` lifecycle events, emitted on session
92
+ start and close.
93
+ - Approval modes: `mode: "plan"` enables plan mode (read-only until
94
+ `plan/approve`); `"auto"` admits an inspectable queue that never blocks.
95
+
96
+ ### Removed
97
+
98
+ - `Server#register_permission_handler` and the SessionManager permission
99
+ handler wiring (the PermissionHandler class remains for direct use).
100
+ - `AgentAdapter#inject_message` (replaced by steer semantics).
101
+
3
102
  ## [0.1.0] - 2026-07-23
4
103
 
5
104
  ### Added
data/README.md CHANGED
@@ -2,21 +2,22 @@
2
2
 
3
3
  [![Gem Version](https://badge.fury.io/rb/ask-app-server.svg)](https://badge.fury.io/rb/ask-app-server)
4
4
 
5
- **JSON-RPC/stdio app-server for ask-rb agents.** Exposes `Ask::Agent::Session`
6
- behind the standard app-server protocol: a vendor-neutral interface for
7
- driving an agent as a service, with sessions, streamed events, approvals, and
8
- turn lifecycle. Any client that implements the protocol can drive your agent,
9
- and the client never needs to know it's talking to Ruby. The same protocol is
10
- what several coding agents use behind their own app-servers (OpenAI's Codex
11
- app-server is one well-known implementation); ask-app-server isn't an
12
- extension of any of them it simply speaks the standard.
5
+ **JSON-RPC/stdio session host for ask-rb agents.** Exposes `Ask::Agent::Session`
6
+ behind the canonical [ask-session-protocol](https://github.com/ask-rb/ask-session-protocol)
7
+ wire contract: one versioned interface for driving an agent as a service,
8
+ with sessions, streamed canonical events, resolvable interactions
9
+ (approvals, plans), and turn lifecycle. Any client that speaks the protocol
10
+ can drive your agent, and the client never needs to know it's talking to
11
+ Ruby. The method surface is app-server compatible — the same shape Codex
12
+ and ZCode expose while the event vocabulary and interaction model are the
13
+ ask ecosystem's own canonical contract.
13
14
 
14
15
  ## What is this?
15
16
 
16
- `ask-app-server` turns an ask-rb agent into a **programmable service** that speaks JSON-RPC over stdio. Any client that can speak the app-server protocol can drive your agent:
17
+ `ask-app-server` turns an ask-rb agent into a **programmable service** that speaks JSON-RPC over stdio. Any client that speaks the canonical session protocol can drive your agent:
17
18
 
18
- - **IDE extensions and editors** — stream model deltas and tool events into an editor surface over stdio or a socket, the same way agent–editor integrations work today
19
- - **Custom chat UIs and desktop apps** — stream model deltas and tool events into your own interface
19
+ - **IDE extensions and editors** — stream model deltas, tool events, and approvals into an editor surface over stdio or a socket
20
+ - **Custom chat UIs and desktop apps** — stream canonical events into your own interface
20
21
  - **Bots and assistants** — drive sessions programmatically from any runtime that can spawn a subprocess
21
22
  - **Headless automation** — CI/CD pipelines, batch processing, scriptable agent tasks
22
23
 
@@ -37,8 +38,30 @@ gem "ask-app-server"
37
38
  ```bash
38
39
  # Start the server (reads JSON-RPC from stdin, writes to stdout)
39
40
  ask-app-server
41
+
42
+ # Or expose a unix socket for multi-client attach (runs alongside stdio):
43
+ ask-app-server --socket ~/.ask-app-server/app-server.sock
40
44
  ```
41
45
 
46
+ Running inside a [herdr](https://herdr.dev) pane? Nothing to configure:
47
+ herdr injects `HERDR_SOCKET_PATH`/`HERDR_PANE_ID` into the pane, the host
48
+ detects them, and keeps the herdr sidebar accurate with first-party state —
49
+ `working` while a turn runs, `blocked` while an approval is pending,
50
+ `idle` otherwise — plus the session id (for future resume) and model
51
+ tokens. No screen scraping needed; the host knows its own state.
52
+
53
+ Clients connect to the socket and speak the same protocol as NDJSON lines
54
+ (`ASK_APP_SERVER_SOCKET` env var works too):
55
+
56
+ ```bash
57
+ nc -U ~/.ask-app-server/app-server.sock
58
+ {"id":1, "method":"initialize", "params":{}}
59
+ ```
60
+
61
+ Any number of clients can attach to the same sessions: each connection
62
+ tracks its own event-delivery cursor, so `session/subscribe` replays what
63
+ that client hasn't seen and every client receives each event exactly once.
64
+
42
65
  From another process, send JSON-RPC requests:
43
66
 
44
67
  ```json
@@ -52,27 +75,41 @@ From another process, send JSON-RPC requests:
52
75
 
53
76
  | Method | Description |
54
77
  |---|---|
55
- | `initialize` | Handshake, returns server capabilities |
78
+ | `initialize` | Handshake; negotiates `protocolVersion` and capabilities |
56
79
  | `session/create` | Create a new agent session |
57
80
  | `session/list` | List active sessions |
58
81
  | `session/resume` | Resume an existing session |
59
- | `session/subscribe` | Subscribe to streaming events |
60
- | `session/send` | Send a message to a session |
82
+ | `session/subscribe` | Subscribe to the event stream (with replay snapshot) |
83
+ | `session/send` | Prompt an idle session or inject mid-run (`steered`/`queued`/`stale`) |
61
84
  | `session/events` | Poll for events after a sequence number |
62
85
  | `session/abort` | Abort the current turn |
63
- | `workspace/readState` | Read model and workspace settings |
86
+ | `session/close` | Close the session |
87
+ | `session/artifacts` · `session/artifact/get` | List and fetch tool artifacts |
88
+ | `interaction/list` | List pending approval interactions |
89
+ | `interaction/approve` · `interaction/reject` | Resolve an approval by id |
90
+ | `interaction/approve-all` · `interaction/reject-all` | Resolve all approvals |
91
+ | `interaction/respond` | Answer user-input elicitation (not yet implemented) |
92
+ | `plan/approve` · `plan/reject` | Approve/reject the pending plan proposal |
93
+ | `workspace/readState` | Read workspace and approval-mode state |
64
94
 
65
95
  ### Events (server → client notifications)
66
96
 
97
+ Every event is a canonical `{type, seq, payload}` envelope, delivered as a
98
+ `session/event` notification on subscribed sessions:
99
+
67
100
  | Event | When |
68
101
  |---|---|
69
- | `turn.started` | A new turn begins processing |
70
- | `model.streaming` | Text delta from the model |
71
- | `tool.updated` | Tool execution started/updated/completed/failed |
72
- | `turn.completed` | Turn finished successfully |
73
- | `turn.failed` | Turn ended with an error |
74
-
75
- Event payloads are delivered as `session/event` notifications on subscribed sessions. The server also sends `interaction/requestPermission` when a blocked tool needs approval and `interaction/requestUserInput` when it needs input from the user.
102
+ | `session.created` · `session.ended` | Session lifecycle |
103
+ | `turn.started` · `turn.completed` · `turn.failed` · `turn.aborted` | Turn lifecycle |
104
+ | `model.streaming` · `model.thinking` | Model output deltas |
105
+ | `tool.use` · `tool.delta` · `tool.result` | Tool execution |
106
+ | `approval.required` · `approval.updated` | Resolvable approval interactions |
107
+ | `plan.proposed` · `plan.approved` · `plan.rejected` | Plan mode |
108
+ | `todos.updated` | Todo list changes |
109
+ | `error` | Non-fatal errors |
110
+
111
+ The full vocabulary, payload shapes, method specs, and versioning live in
112
+ the ask-session-protocol gem (JSON Schema artifact included).
76
113
 
77
114
  ## Clients
78
115
 
@@ -95,7 +132,7 @@ Environment variables:
95
132
  |---|---|---|
96
133
  | `ASK_APP_SERVER_CONFIG` | auto-detected | Path to the config file |
97
134
  | `ASK_APP_SERVER_MODEL` | `opencode_go/deepseek-v4-flash` | Model identifier (overrides config file) |
98
- | `ASK_APP_SERVER_PERMISSIONS` | `on_request` | Permission mode (`on_request`, `never`) |
135
+ | `ASK_APP_SERVER_PERMISSIONS` | `on_request` | Permission mode (`on_request`, `never`, `auto`) |
99
136
  | `DEBUG` | unset | Set to `1` for debug logging |
100
137
 
101
138
  ## Development
@@ -2,11 +2,14 @@
2
2
 
3
3
  module Ask
4
4
  module AppServer
5
- # Wraps an Ask::Agent::Session and translates its events into the
6
- # app-server protocol event format via EventTranslator.
5
+ # Wraps an Ask::Agent::Session and translates its events into canonical
6
+ # Ask::SessionProtocol events via EventTranslator.
7
7
  #
8
8
  # Each wrapper is associated with one session and maintains an
9
- # EventTranslator that app-server clients poll or subscribe to.
9
+ # EventTranslator that clients poll or subscribe to. The adapter also
10
+ # owns the approval queue wiring (approval events surface as
11
+ # approval.required / approval.updated) and the interaction controls
12
+ # (approve/reject by id, plan approve/reject) that any client can call.
10
13
  class AgentAdapter
11
14
  # The underlying ask-agent session.
12
15
  attr_reader :session
@@ -23,14 +26,27 @@ module Ask
23
26
  # When the session was created.
24
27
  attr_reader :created_at
25
28
 
26
- def initialize(model:, tools: nil, system_prompt: nil, agent_dir: nil, **session_opts)
29
+ # @param model [String] model identifier
30
+ # @param tools [Array<String, Class>] tool names or classes
31
+ # @param system_prompt [String, nil]
32
+ # @param agent_dir [String, nil] workspace path
33
+ # @param approval [Symbol] :off, :require, or :auto
34
+ # @param require_approval [Array<String>, nil] tool names gated behind
35
+ # human approval when approval is :require
36
+ # @param session_opts [Hash] remaining options passed to
37
+ # Ask::Agent::Session.new (hooks, plan_mode, todos, ...)
38
+ def initialize(model:, tools: nil, system_prompt: nil, agent_dir: nil,
39
+ approval: :off, require_approval: nil, **session_opts)
27
40
  @model = model
28
41
  @system_prompt = system_prompt
29
42
  @tools = resolve_tools(tools)
30
43
  @session_opts = session_opts
44
+ @approval = approval
45
+ @require_approval = require_approval
31
46
  @agent_dir = agent_dir
32
47
  @session = nil
33
48
  @translator = nil
49
+ @on_event_block = nil
34
50
  @session_id = nil
35
51
  @running = false
36
52
  @running_mutex = Mutex.new
@@ -43,22 +59,12 @@ module Ask
43
59
  # Start a new ask-agent session.
44
60
  # Returns the session ID.
45
61
  def start_session
46
- opts = @session_opts.dup
47
-
48
- # Extract hooks from session opts and pass them to Session
49
- hooks = opts.delete(:hooks) || {}
50
-
51
- @session = Ask::Agent::Session.new(
52
- model: @model,
53
- tools: @tools,
54
- system_prompt: @system_prompt,
55
- agent_dir: @agent_dir,
56
- hooks: hooks,
57
- **opts
58
- )
62
+ @translator = EventTranslator.new
63
+ @translator.on_event = @on_event_block if @on_event_block
64
+ @session = build_session
59
65
  @session_id = @session.id
60
- @translator = EventTranslator.new(@session_id)
61
66
  @session.on_event { |event| handle_agent_event(event) }
67
+ @translator.session_created(@session_id)
62
68
  @session_id
63
69
  end
64
70
 
@@ -66,34 +72,45 @@ module Ask
66
72
  def resume(session)
67
73
  @session = session
68
74
  @session_id = session.id
69
- @translator = EventTranslator.new(@session_id)
75
+ @translator = EventTranslator.new
76
+ @translator.on_event = @on_event_block if @on_event_block
70
77
  @session.on_event { |event| handle_agent_event(event) }
71
78
  @session_id
72
79
  end
73
80
 
74
- # Send a message and start processing. Runs in a background thread.
75
- # The caller should poll or subscribe to receive events.
76
- def send_message(content)
77
- raise "Session not started" unless @session
78
- raise "Session already busy" if @running
79
-
80
- @running_mutex.synchronize do
81
- @abort_requested = false
82
- @running = true
81
+ # Register an observer for every canonical event this session emits
82
+ # (translations plus approval/plan/session-lifecycle emissions).
83
+ # May be called before start_session; the block is applied when the
84
+ # translator exists so the observer sees session.created.
85
+ def on_event(&block)
86
+ if @translator
87
+ @translator.on_event = block
88
+ else
89
+ @on_event_block = block
83
90
  end
91
+ end
84
92
 
85
- @run_thread = Thread.new do
86
- begin
87
- @session.run(content)
88
- rescue => e
89
- # Agent may have been aborted that's fine
90
- @logger.debug("Agent run error: #{e.message}") if ENV["DEBUG"]
91
- ensure
92
- @running_mutex.synchronize { @running = false }
93
- end
94
- end
93
+ # Send a message and start processing (idle session) or inject it
94
+ # mid-run (running session). Uses ask-agent's steer semantics:
95
+ #
96
+ # steered the message was added to the conversation immediately
97
+ # queued — the session is running; the message is queued for the
98
+ # next turn boundary (no abort)
99
+ # stale — the caller's expected_turn_id no longer matches
100
+ #
101
+ # @param content [String]
102
+ # @param expected_turn_id [String, nil] staleness guard
103
+ # @return [Hash] { status: "steered"|"queued"|"stale", turn_id: }
104
+ def send_message(content, expected_turn_id: nil)
105
+ raise "Session not started" unless @session
95
106
 
96
- true
107
+ if @running
108
+ result = @session.steer(content, expected_turn_id: expected_turn_id)
109
+ { status: result[:status].to_s, turn_id: result[:turn_id] }
110
+ else
111
+ start_run(content)
112
+ { status: "steered", turn_id: @session.turn_id }
113
+ end
97
114
  end
98
115
 
99
116
  # Request abort of the current turn.
@@ -117,23 +134,84 @@ module Ask
117
134
  !@running
118
135
  end
119
136
 
120
- # Inject a message into a running session (mid-execution).
121
- # Aborts the current turn; the message will be processed when
122
- # the next turn starts.
123
- # Note: ask-agent doesn't natively support mid-execution injection.
124
- # We abort and queue the message for the next run.
125
- def inject_message(content)
126
- if @running
127
- abort_turn!
128
- # The caller should wait for idle, then call send_message again
129
- false
130
- else
131
- send_message(content)
132
- true
137
+ # ── Interactions (approvals) ───────────────────────────────────────
138
+
139
+ # Pending approval interactions, as canonical Interaction objects.
140
+ def pending_interactions
141
+ queue = @session&.approval_queue
142
+ return [] unless queue
143
+
144
+ queue.pending_actions.map do |action|
145
+ payload = { "toolName" => action.tool_name.to_s }
146
+ payload["args"] = action.args if action.args
147
+ payload["message"] = action.message if action.message
148
+ payload["autoApprovable"] = action.auto_approvable unless action.auto_approvable.nil?
149
+ Ask::SessionProtocol::Interactions.interaction(
150
+ id: "act_#{action.id}", kind: "approval", status: "pending", payload: payload
151
+ )
133
152
  end
134
153
  end
135
154
 
136
- # The accumulated streaming text from the current/ last turn.
155
+ # Approve a pending approval interaction by canonical id ("act_N").
156
+ # @return [Boolean] whether an action was approved
157
+ def approve_interaction(interaction_id)
158
+ apply_interaction(interaction_id) { |queue, id| queue.approve(id) }
159
+ end
160
+
161
+ # Reject a pending approval interaction by canonical id ("act_N").
162
+ # @return [Boolean] whether an action was rejected
163
+ def reject_interaction(interaction_id)
164
+ apply_interaction(interaction_id) { |queue, id| queue.reject(id) }
165
+ end
166
+
167
+ # Approve every pending approval interaction.
168
+ # @return [Integer] number approved
169
+ def approve_all_interactions
170
+ queue = @session&.approval_queue
171
+ return 0 unless queue
172
+
173
+ queue.approve_all.size
174
+ end
175
+
176
+ # Reject every pending approval interaction.
177
+ # @return [Integer] number rejected
178
+ def reject_all_interactions
179
+ queue = @session&.approval_queue
180
+ return 0 unless queue
181
+
182
+ queue.reject_all.size
183
+ end
184
+
185
+ # ── Plan mode ───────────────────────────────────────────────────────
186
+
187
+ # Approve the pending plan proposal.
188
+ # @return [Boolean]
189
+ def plan_approve
190
+ queue = @session&.plan_queue
191
+ return false unless queue
192
+
193
+ queue.approve_all.any?
194
+ end
195
+
196
+ # Reject the pending plan proposal; the agent stays in plan mode.
197
+ # @return [Boolean]
198
+ def plan_reject
199
+ queue = @session&.plan_queue
200
+ return false unless queue
201
+
202
+ queue.reject_all.any?
203
+ end
204
+
205
+ # ── Lifecycle ───────────────────────────────────────────────────────
206
+
207
+ # Close the session: delete its state and emit session.ended.
208
+ def close!
209
+ @session&.delete if @session.respond_to?(:delete)
210
+ @translator&.session_ended(@session_id, reason: "closed")
211
+ true
212
+ end
213
+
214
+ # The accumulated streaming text from the current/last turn.
137
215
  def streaming_text
138
216
  @translator&.instance_variable_get(:@streaming_text).to_s
139
217
  end
@@ -155,15 +233,79 @@ module Ask
155
233
 
156
234
  # Events after a given sequence number.
157
235
  def events_after(after_seq)
158
- pending_events.select { |e| e[:seq] > after_seq }
236
+ pending_events.select { |e| e.seq > after_seq }
159
237
  end
160
238
 
161
239
  private
162
240
 
241
+ def build_session
242
+ opts = @session_opts.dup
243
+ hooks = opts.delete(:hooks) || {}
244
+ approval_option = build_approval_option
245
+ opts[:approval] = approval_option if approval_option
246
+ opts[:plan_mode] = false unless opts.key?(:plan_mode)
247
+
248
+ Ask::Agent::Session.new(
249
+ model: @model,
250
+ tools: @tools,
251
+ system_prompt: @system_prompt,
252
+ agent_dir: @agent_dir,
253
+ hooks: hooks,
254
+ **opts
255
+ )
256
+ end
257
+
258
+ # Build the approval option for Ask::Agent::Session: an
259
+ # EmittingApprovalQueue whose events stream to the translator.
260
+ def build_approval_option
261
+ return nil if @approval == :off
262
+
263
+ queue = EmittingApprovalQueue.new(
264
+ on_submit: ->(action) { @translator.approval_required(action) },
265
+ on_status: ->(action) { @translator.approval_updated(action) }
266
+ )
267
+ return { queue: queue } if @approval == :auto
268
+
269
+ { queue: queue, require_approval: @require_approval }
270
+ end
271
+
272
+ def apply_interaction(interaction_id)
273
+ id = interaction_id.to_s[/\Aact_(\d+)\z/, 1]
274
+ return false unless id
275
+
276
+ queue = @session&.approval_queue
277
+ return false unless queue
278
+
279
+ yield(queue, id.to_i).any?
280
+ end
281
+
282
+ def start_run(content)
283
+ @running_mutex.synchronize do
284
+ @abort_requested = false
285
+ @running = true
286
+ end
287
+
288
+ @run_thread = Thread.new do
289
+ begin
290
+ @session.run(content, reset: false)
291
+ # Drain steers queued while the turn was running (mid-execution
292
+ # injection): each subsequent run processes one queued message.
293
+ while @session.queued_steers.positive?
294
+ @session.run("", reset: false)
295
+ end
296
+ rescue => e
297
+ # Agent may have been aborted — that's fine
298
+ @logger.debug("Agent run error: #{e.message}") if ENV["DEBUG"]
299
+ ensure
300
+ @running_mutex.synchronize { @running = false }
301
+ end
302
+ end
303
+
304
+ true
305
+ end
306
+
163
307
  def handle_agent_event(event)
164
- translated = @translator.translate(event)
165
- # Translated events are already stored in the translator's buffer.
166
- # Nothing else to do here — clients poll or subscribe to get them.
308
+ @translator.translate(event)
167
309
  end
168
310
 
169
311
  def resolve_tools(tool_list)
@@ -202,5 +344,45 @@ module Ask
202
344
  ]
203
345
  end
204
346
  end
347
+
348
+ # Approval queue that emits canonical approval events through the
349
+ # session's EventTranslator whenever actions are submitted or change
350
+ # status, so clients can stream approval state in real time.
351
+ #
352
+ # The session wires its own apply/reject/submit callbacks onto the
353
+ # queue (see Session#build_approval); this subclass only adds
354
+ # observation hooks on top, using its own listeners so the session's
355
+ # on_submit (pending-tool registration) is never clobbered.
356
+ class EmittingApprovalQueue < Ask::Agent::ApprovalQueue
357
+ # @param on_submit [Proc, nil] called with the new {Action} after
358
+ # submission (and after the auto-approval drain)
359
+ # @param on_status [Proc, nil] called with an {Action} whose status
360
+ # changed to :approved or :rejected
361
+ def initialize(on_submit: nil, on_status: nil, **kwargs)
362
+ @on_action_submitted = on_submit
363
+ @on_status = on_status
364
+ super(**kwargs)
365
+ end
366
+
367
+ def submit(tool_call_id:, tool_name:, args: {}, auto_approvable: false, message: nil)
368
+ id = super
369
+ @on_action_submitted&.call(self[id])
370
+ id
371
+ end
372
+
373
+ private
374
+
375
+ def apply(action)
376
+ result = super
377
+ @on_status&.call(action.with(status: :approved))
378
+ result
379
+ end
380
+
381
+ def reject_action(action)
382
+ result = super
383
+ @on_status&.call(action.with(status: :rejected))
384
+ result
385
+ end
386
+ end
205
387
  end
206
388
  end