ask-coding-providers 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/lib/ask/coding_providers/acp/adapter.rb +175 -0
- data/lib/ask/coding_providers/acp.rb +11 -0
- data/lib/ask/coding_providers/adapter.rb +152 -0
- data/lib/ask/coding_providers/ask_agent/adapter.rb +182 -0
- data/lib/ask/coding_providers/ask_agent.rb +19 -0
- data/lib/ask/coding_providers/claude/adapter.rb +182 -0
- data/lib/ask/coding_providers/claude.rb +11 -0
- data/lib/ask/coding_providers/codex/adapter.rb +200 -0
- data/lib/ask/coding_providers/codex/app_server_client.rb +261 -0
- data/lib/ask/coding_providers/codex/codex_db.rb +210 -0
- data/lib/ask/coding_providers/codex.rb +14 -0
- data/lib/ask/coding_providers/version.rb +7 -0
- data/lib/ask/coding_providers/zcode/zcode_db.rb +195 -0
- data/lib/ask/coding_providers/zcode.rb +12 -0
- data/lib/ask/coding_providers.rb +53 -0
- data/lib/ask-coding-providers.rb +3 -0
- metadata +115 -0
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "open3"
|
|
5
|
+
|
|
6
|
+
module Ask
|
|
7
|
+
module CodingProviders
|
|
8
|
+
module Claude
|
|
9
|
+
# Adapter for Claude Code CLI.
|
|
10
|
+
#
|
|
11
|
+
# Uses `claude -p --output-format=stream-json` for non-interactive prompts.
|
|
12
|
+
# Streams JSON events via stdout for real-time response display.
|
|
13
|
+
#
|
|
14
|
+
# @example
|
|
15
|
+
# adapter = Ask::CodingProviders::Claude::Adapter.new(cli_path: "claude")
|
|
16
|
+
# adapter.start
|
|
17
|
+
# sid = adapter.create_session("/tmp")
|
|
18
|
+
# adapter.send_and_stream(sid, "Hello") { |ev| puts ev }
|
|
19
|
+
class Adapter < Ask::CodingProviders::Adapter
|
|
20
|
+
def initialize(cli_path: "claude", cwd: ".", request_timeout: 120.0)
|
|
21
|
+
@cli_path = cli_path
|
|
22
|
+
@cwd = cwd
|
|
23
|
+
@request_timeout = request_timeout
|
|
24
|
+
@started = false
|
|
25
|
+
@sessions = {}
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def start
|
|
29
|
+
@started = true
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def stop
|
|
33
|
+
@started = false
|
|
34
|
+
@sessions = {}
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def running?
|
|
38
|
+
@started
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def create_session(workspace_path, mode: nil)
|
|
42
|
+
ensure_started
|
|
43
|
+
sid = "sess_#{SecureRandom.uuid}"
|
|
44
|
+
@sessions[sid] = { workspace: workspace_path, mode: mode, created_at: Time.now }
|
|
45
|
+
sid
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def resume_session(session_id)
|
|
49
|
+
@sessions&.dig(session_id) || {}
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def subscribe(session_id, after_seq: 0)
|
|
53
|
+
{ "eventSeq" => 0 }
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def send_message(session_id, content)
|
|
57
|
+
ensure_started
|
|
58
|
+
run_claude(content)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def send_and_stream(session_id, content, turn_timeout: 600.0, &block)
|
|
62
|
+
return enum_for(:send_and_stream, session_id, content, turn_timeout: turn_timeout) unless block
|
|
63
|
+
ensure_started
|
|
64
|
+
|
|
65
|
+
block.call({ type: "turn.started", seq: 1, payload: { "sessionId" => session_id } })
|
|
66
|
+
|
|
67
|
+
deadline = Time.now + (turn_timeout || @request_timeout)
|
|
68
|
+
accumulated = ""
|
|
69
|
+
|
|
70
|
+
begin
|
|
71
|
+
stdin, stdout, stderr, wait_thr = Open3.popen3(
|
|
72
|
+
@cli_path, "-p", content.to_s,
|
|
73
|
+
"--verbose", "--output-format=stream-json",
|
|
74
|
+
chdir: @cwd
|
|
75
|
+
)
|
|
76
|
+
stdin.close
|
|
77
|
+
|
|
78
|
+
# Read JSON events from stdout
|
|
79
|
+
stdout.each_line do |line|
|
|
80
|
+
break if Time.now > deadline
|
|
81
|
+
line = line.strip
|
|
82
|
+
next if line.empty?
|
|
83
|
+
|
|
84
|
+
begin
|
|
85
|
+
event = JSON.parse(line)
|
|
86
|
+
rescue JSON::ParserError
|
|
87
|
+
next
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
case event["type"]
|
|
91
|
+
when "assistant"
|
|
92
|
+
msg = event["message"] || {}
|
|
93
|
+
(msg["content"] || []).each do |block|
|
|
94
|
+
if block["type"] == "text" && block["text"]
|
|
95
|
+
delta = block["text"]
|
|
96
|
+
accumulated += delta
|
|
97
|
+
block.call({
|
|
98
|
+
type: "model.streaming", seq: 2,
|
|
99
|
+
payload: { "delta" => delta, "sessionId" => session_id }
|
|
100
|
+
})
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
when "result"
|
|
104
|
+
is_error = event["is_error"]
|
|
105
|
+
if is_error
|
|
106
|
+
err = event["result"] || "Claude error"
|
|
107
|
+
block.call({
|
|
108
|
+
type: "turn.failed", seq: 3,
|
|
109
|
+
payload: { "error" => { "message" => err }, "sessionId" => session_id }
|
|
110
|
+
})
|
|
111
|
+
else
|
|
112
|
+
block.call({
|
|
113
|
+
type: "turn.completed", seq: 3,
|
|
114
|
+
payload: { "response" => accumulated, "sessionId" => session_id }
|
|
115
|
+
})
|
|
116
|
+
end
|
|
117
|
+
when "error"
|
|
118
|
+
err_msg = event["message"] || event["error"] || "Claude error"
|
|
119
|
+
block.call({
|
|
120
|
+
type: "turn.failed", seq: 3,
|
|
121
|
+
payload: { "error" => { "message" => err_msg }, "sessionId" => session_id }
|
|
122
|
+
})
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
stdout.close rescue nil
|
|
127
|
+
stderr.close rescue nil
|
|
128
|
+
wait_thr&.join(5)
|
|
129
|
+
|
|
130
|
+
rescue => e
|
|
131
|
+
block.call({
|
|
132
|
+
type: "turn.failed", seq: 3,
|
|
133
|
+
payload: { "error" => { "message" => e.message }, "sessionId" => session_id }
|
|
134
|
+
})
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def get_events(session_id, after_seq:, limit: nil)
|
|
139
|
+
{}
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def respond(request_id, result)
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def get_workspace_state(workspace_path)
|
|
146
|
+
{}
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def self.from_config(workspace_path: Dir.pwd, cli_path: nil, request_timeout: 120, **)
|
|
150
|
+
new(cli_path: cli_path || "claude", cwd: workspace_path, request_timeout: request_timeout)
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
private
|
|
154
|
+
|
|
155
|
+
def ensure_started
|
|
156
|
+
raise Error, "Claude adapter not started" unless @started
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def run_claude(content)
|
|
160
|
+
stdout, stderr, wait_thr = Open3.capture3(
|
|
161
|
+
@cli_path, "-p", content.to_s,
|
|
162
|
+
"--verbose", "--output-format=stream-json",
|
|
163
|
+
chdir: @cwd
|
|
164
|
+
)
|
|
165
|
+
events = stdout.split("\n").filter_map { |l| JSON.parse(l) rescue nil }
|
|
166
|
+
result = events.find { |e| e["type"] == "result" }
|
|
167
|
+
if result && result["is_error"]
|
|
168
|
+
{ "error" => result["result"] || "Claude error" }
|
|
169
|
+
else
|
|
170
|
+
text = events
|
|
171
|
+
.select { |e| e["type"] == "assistant" }
|
|
172
|
+
.flat_map { |e| (e.dig("message", "content") || []).map { |c| c["text"] } }
|
|
173
|
+
.join
|
|
174
|
+
{ "response" => text }
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
Ask::CodingProviders.register_adapter(:claude, Ask::CodingProviders::Claude::Adapter)
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ask
|
|
4
|
+
module CodingProviders
|
|
5
|
+
module Codex
|
|
6
|
+
# Adapter implementation for the Codex app-server.
|
|
7
|
+
#
|
|
8
|
+
# Communicates with `codex app-server` over stdio JSON-RPC 2.0.
|
|
9
|
+
# Supports thread (session) lifecycle and streaming turns.
|
|
10
|
+
#
|
|
11
|
+
# Note: Codex does not speak ACP. Use this adapter for Codex.
|
|
12
|
+
# For agents that support ACP (Claude, OpenCode, Gemini CLI), use
|
|
13
|
+
# {Ask::CodingProviders::ACP::Adapter} instead.
|
|
14
|
+
class Adapter < Ask::CodingProviders::Adapter
|
|
15
|
+
def initialize(cwd: ".", cli_path: nil, request_timeout: 60.0, model: nil, model_provider: nil)
|
|
16
|
+
@cwd = cwd
|
|
17
|
+
@cli_path = cli_path
|
|
18
|
+
@request_timeout = request_timeout
|
|
19
|
+
@model = model || ENV["CODEX_MODEL"]
|
|
20
|
+
@model_provider = model_provider || ENV["CODEX_MODEL_PROVIDER"]
|
|
21
|
+
@client = nil
|
|
22
|
+
@codex_db = CodexDB.new
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def start
|
|
26
|
+
return if @client
|
|
27
|
+
@client = AppServerClient.new(cwd: @cwd, cli_path: @cli_path, request_timeout: @request_timeout,
|
|
28
|
+
model: @model, model_provider: @model_provider)
|
|
29
|
+
@client.on_notification { |method, params, request_id| handle_notification(method, params, request_id) }
|
|
30
|
+
@client.start
|
|
31
|
+
# Perform the initialize handshake
|
|
32
|
+
@client.initialize!
|
|
33
|
+
@client.send_initialized
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def stop
|
|
37
|
+
@client&.stop
|
|
38
|
+
@client = nil
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def running?
|
|
42
|
+
@client&.running? || false
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def create_session(workspace_path, mode: nil)
|
|
46
|
+
ensure_running
|
|
47
|
+
thread = @client.thread_start(cwd: workspace_path)
|
|
48
|
+
tid = thread["id"] || thread[:id]
|
|
49
|
+
raise Error, "thread/start did not return thread id" unless tid
|
|
50
|
+
tid
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def resume_session(session_id)
|
|
54
|
+
ensure_running
|
|
55
|
+
@client.thread_resume(session_id)
|
|
56
|
+
rescue => e
|
|
57
|
+
{}
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def subscribe(session_id, after_seq: 0)
|
|
61
|
+
ensure_running
|
|
62
|
+
{ "eventSeq" => 0 }
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def send_message(session_id, content)
|
|
66
|
+
ensure_running
|
|
67
|
+
@client.turn_start(session_id, content)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def send_and_stream(session_id, content, turn_timeout: 600.0, &block)
|
|
71
|
+
return enum_for(:send_and_stream, session_id, content, turn_timeout: turn_timeout) unless block
|
|
72
|
+
raise "send_and_stream requires app-server mode" unless @client
|
|
73
|
+
|
|
74
|
+
ev_queue = Queue.new
|
|
75
|
+
my_turn_id = nil
|
|
76
|
+
done = false
|
|
77
|
+
|
|
78
|
+
handler = ->(method, params, request_id) do
|
|
79
|
+
case method
|
|
80
|
+
when "turn/started"
|
|
81
|
+
my_turn_id = params["turnId"] || params[:turnId]
|
|
82
|
+
ev_queue << {
|
|
83
|
+
type: "turn.started", seq: params["seq"] || params[:seq] || 0,
|
|
84
|
+
payload: { "sessionId" => session_id }
|
|
85
|
+
}
|
|
86
|
+
when "item/agentMessage/delta"
|
|
87
|
+
tid = params["turnId"] || params[:turnId]
|
|
88
|
+
if my_turn_id.nil? || tid == my_turn_id
|
|
89
|
+
ev_queue << {
|
|
90
|
+
type: "model.streaming", seq: params["seq"] || params[:seq] || 0,
|
|
91
|
+
payload: { "delta" => params["content"] || params[:content] || "", "sessionId" => session_id }
|
|
92
|
+
}
|
|
93
|
+
end
|
|
94
|
+
when "turn/completed"
|
|
95
|
+
tid = params["turnId"] || params[:turnId]
|
|
96
|
+
if my_turn_id.nil? || tid == my_turn_id
|
|
97
|
+
response = params.dig("response", "message") || params.dig("turn", "response") || ""
|
|
98
|
+
ev_queue << {
|
|
99
|
+
type: "turn.completed", seq: params["seq"] || params[:seq] || 0,
|
|
100
|
+
payload: { "response" => response, "sessionId" => session_id,
|
|
101
|
+
"tokenCount" => params["tokenCount"] || params[:tokenCount] || 0 }
|
|
102
|
+
}
|
|
103
|
+
done = true
|
|
104
|
+
end
|
|
105
|
+
when "turn/failed"
|
|
106
|
+
error_msg = params.dig("error", "message") || "Turn failed"
|
|
107
|
+
ev_queue << {
|
|
108
|
+
type: "turn.failed", seq: params["seq"] || params[:seq] || 0,
|
|
109
|
+
payload: { "error" => { "message" => error_msg }, "sessionId" => session_id }
|
|
110
|
+
}
|
|
111
|
+
done = true
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
@client.on_notification(&handler)
|
|
116
|
+
begin
|
|
117
|
+
@client.turn_start(session_id, content)
|
|
118
|
+
deadline = Time.now + turn_timeout
|
|
119
|
+
until done
|
|
120
|
+
remaining = deadline - Time.now
|
|
121
|
+
break if remaining <= 0
|
|
122
|
+
begin
|
|
123
|
+
block.call(ev_queue.pop(timeout: remaining))
|
|
124
|
+
rescue ThreadError
|
|
125
|
+
break
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def get_events(session_id, after_seq:, limit: nil)
|
|
132
|
+
ensure_running
|
|
133
|
+
{}
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def respond(request_id, result)
|
|
137
|
+
@client&.respond(request_id, result)
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def get_workspace_state(workspace_path)
|
|
141
|
+
ensure_running
|
|
142
|
+
@client.read_workspace_state
|
|
143
|
+
rescue => e
|
|
144
|
+
{}
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# Build a Codex adapter from config.
|
|
148
|
+
def self.from_config(workspace_path: Dir.pwd, cli_path: nil, request_timeout: 600, model: nil, model_provider: nil, **)
|
|
149
|
+
new(cwd: workspace_path, cli_path: cli_path, request_timeout: request_timeout,
|
|
150
|
+
model: model, model_provider: model_provider)
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# ── Session/project queries (delegated to CodexDB) ──
|
|
154
|
+
|
|
155
|
+
def list_projects
|
|
156
|
+
@codex_db.list_projects
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def find_sessions(directory:, limit: 20)
|
|
160
|
+
@codex_db.find_sessions(directory: directory, limit: limit)
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def find_recent_session
|
|
164
|
+
@codex_db.find_recent_session
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def find_recent_tui_session(workspace_path)
|
|
168
|
+
@codex_db.find_recent_tui_session(workspace_path)
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def session_directory(session_id)
|
|
172
|
+
@codex_db.session_directory(session_id)
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def session_history(session_id, limit: 100)
|
|
176
|
+
@codex_db.session_history(session_id, limit: limit)
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def recent_sessions
|
|
180
|
+
@codex_db.recent_sessions
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
private
|
|
184
|
+
|
|
185
|
+
def ensure_running
|
|
186
|
+
raise Error, "Codex app-server is not running. Call #start first." unless @client&.running?
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def handle_notification(method, params, request_id)
|
|
190
|
+
# Auto-respond to user input requests
|
|
191
|
+
return unless request_id
|
|
192
|
+
return unless method == "ToolRequestUserInput"
|
|
193
|
+
@client&.respond(request_id, { "response" => "" })
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
Ask::CodingProviders.register_adapter(:codex, Ask::CodingProviders::Codex::Adapter)
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "open3"
|
|
5
|
+
|
|
6
|
+
module Ask
|
|
7
|
+
module CodingProviders
|
|
8
|
+
module Codex
|
|
9
|
+
# JSON-RPC 2.0 client for the Codex app-server (stdio transport).
|
|
10
|
+
#
|
|
11
|
+
# Protocol: https://learn.chatgpt.com/docs/app-server
|
|
12
|
+
#
|
|
13
|
+
# Spawns `codex app-server` as a subprocess and communicates over stdio
|
|
14
|
+
# using newline-delimited JSON.
|
|
15
|
+
class AppServerClient
|
|
16
|
+
CLI_PATHS = [
|
|
17
|
+
-> { ENV["CODEX_CLI_PATH"] },
|
|
18
|
+
-> { find_in_path("codex") },
|
|
19
|
+
].freeze
|
|
20
|
+
|
|
21
|
+
attr_reader :cli_path
|
|
22
|
+
|
|
23
|
+
def initialize(cwd: ".", cli_path: nil, request_timeout: 30.0, model: nil, model_provider: nil)
|
|
24
|
+
@cwd = cwd
|
|
25
|
+
@cli_path = cli_path || self.class.resolve_cli_path
|
|
26
|
+
@request_timeout = request_timeout
|
|
27
|
+
@model = model
|
|
28
|
+
@model_provider = model_provider
|
|
29
|
+
@extra_args = build_extra_args
|
|
30
|
+
@mutex = Mutex.new
|
|
31
|
+
@stdin = nil
|
|
32
|
+
@event_handlers = []
|
|
33
|
+
@pending = {}
|
|
34
|
+
@next_id = 0
|
|
35
|
+
@started = false
|
|
36
|
+
@stdout_queue = Queue.new
|
|
37
|
+
@initialized = false
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def start
|
|
41
|
+
@mutex.synchronize do
|
|
42
|
+
return if @started
|
|
43
|
+
@stdin, stdout, stderr, @wait_thr = Open3.popen3(@cli_path, "app-server", "--stdio", *@extra_args, chdir: @cwd)
|
|
44
|
+
|
|
45
|
+
@stdout_thread = Thread.new(stdout) do |io|
|
|
46
|
+
io.each_line do |line|
|
|
47
|
+
line = line.strip
|
|
48
|
+
@stdout_queue << line unless line.empty?
|
|
49
|
+
end
|
|
50
|
+
@stdout_queue << nil
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Drain stderr to avoid deadlocks
|
|
54
|
+
Thread.new(stderr) { |io| io.each_line { |_| } }
|
|
55
|
+
|
|
56
|
+
@dispatcher = Thread.new do
|
|
57
|
+
while (line = @stdout_queue.pop)
|
|
58
|
+
begin
|
|
59
|
+
handle_message(JSON.parse(line))
|
|
60
|
+
rescue JSON::ParserError
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
rescue => e
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
@started = true
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def stop
|
|
71
|
+
@mutex.synchronize do
|
|
72
|
+
return unless @started
|
|
73
|
+
@started = false
|
|
74
|
+
@stdin&.close rescue nil
|
|
75
|
+
@stdout_thread&.join(3) rescue nil
|
|
76
|
+
Process.kill("TERM", @wait_thr.pid) rescue nil
|
|
77
|
+
@wait_thr&.join(5) rescue nil
|
|
78
|
+
@stdin = nil
|
|
79
|
+
@pending.each_value { |f| f[:error] = Error.new("process exited"); f[:done] = true; f[:condition].signal }
|
|
80
|
+
@pending.clear
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def running?
|
|
85
|
+
@started && @wait_thr&.alive?
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def on_notification(&handler)
|
|
89
|
+
@mutex.synchronize { @event_handlers << handler }
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Perform the JSON-RPC initialize handshake.
|
|
93
|
+
# Must be called before any other request.
|
|
94
|
+
def initialize!
|
|
95
|
+
result = request("initialize", {
|
|
96
|
+
protocolVersion: "0.1.0",
|
|
97
|
+
clientInfo: { name: "askoda", version: "0.1.0" },
|
|
98
|
+
capabilities: {}
|
|
99
|
+
})
|
|
100
|
+
@initialized = true
|
|
101
|
+
result
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def send_initialized
|
|
105
|
+
return unless @initialized
|
|
106
|
+
# The 'initialized' notification is a JSON-RPC 2.0 notification (no id)
|
|
107
|
+
@mutex.synchronize do
|
|
108
|
+
write_line({ jsonrpc: "2.0", method: "initialized", params: {} })
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# -- High-level API --
|
|
113
|
+
|
|
114
|
+
def thread_start(cwd: nil)
|
|
115
|
+
ensure_initialized
|
|
116
|
+
params = {
|
|
117
|
+
cwd: cwd || @cwd,
|
|
118
|
+
approvalPolicy: "never",
|
|
119
|
+
sandbox: "read-only"
|
|
120
|
+
}
|
|
121
|
+
result = request("thread/start", params)
|
|
122
|
+
result&.dig("thread") || result&.dig(:thread) || {}
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def thread_resume(thread_id)
|
|
126
|
+
ensure_initialized
|
|
127
|
+
request("thread/resume", { threadId: thread_id })
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def turn_start(thread_id, input)
|
|
131
|
+
ensure_initialized
|
|
132
|
+
input_items = input.is_a?(Array) ? input : [{ type: "text", text: input.to_s }]
|
|
133
|
+
request("turn/start", {
|
|
134
|
+
threadId: thread_id,
|
|
135
|
+
input: input_items,
|
|
136
|
+
cwd: @cwd
|
|
137
|
+
})
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def thread_list(limit: 20)
|
|
141
|
+
ensure_initialized
|
|
142
|
+
request("thread/list", { limit: limit })
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def read_workspace_state
|
|
146
|
+
ensure_initialized
|
|
147
|
+
request("config/read", {})
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def model_list
|
|
151
|
+
ensure_initialized
|
|
152
|
+
request("model/list", {})
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def respond(request_id, result)
|
|
156
|
+
ensure_initialized
|
|
157
|
+
@mutex.synchronize { write_line({ id: request_id, result: result }) }
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def request(method, params = nil, timeout: nil)
|
|
161
|
+
ensure_running
|
|
162
|
+
future = { done: false, result: nil, error: nil, condition: ConditionVariable.new }
|
|
163
|
+
|
|
164
|
+
@mutex.synchronize do
|
|
165
|
+
@next_id += 1
|
|
166
|
+
@pending[@next_id] = future
|
|
167
|
+
write_line({ jsonrpc: "2.0", id: @next_id, method: method, params: params }.compact)
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
timeout ||= @request_timeout
|
|
171
|
+
deadline = Time.now + timeout
|
|
172
|
+
|
|
173
|
+
@mutex.synchronize do
|
|
174
|
+
until future[:done]
|
|
175
|
+
remaining = deadline - Time.now
|
|
176
|
+
raise TimeoutError, "Request timed out after #{timeout}s" if remaining <= 0
|
|
177
|
+
future[:condition].wait(@mutex, remaining)
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
raise future[:error] if future[:error]
|
|
182
|
+
future[:result]
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def self.resolve_cli_path
|
|
186
|
+
CLI_PATHS.each do |resolver|
|
|
187
|
+
path = resolver.call
|
|
188
|
+
return path if path && File.exist?(path)
|
|
189
|
+
end
|
|
190
|
+
# Check the npm global install path
|
|
191
|
+
npm_path = File.expand_path("~/.asdf/installs/nodejs/*/lib/node_modules/@openai/codex/bin/codex.js")
|
|
192
|
+
Dir[npm_path].each { |p| return p if File.exist?(p) }
|
|
193
|
+
raise Error, "Cannot find Codex CLI. Set CODEX_CLI_PATH or install Codex."
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
private
|
|
197
|
+
|
|
198
|
+
def build_extra_args
|
|
199
|
+
args = []
|
|
200
|
+
args += ["-c", "model=#{@model}"] if @model
|
|
201
|
+
args += ["-c", "model_provider=#{@model_provider}"] if @model_provider
|
|
202
|
+
args
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
def ensure_running
|
|
206
|
+
raise Error, "app-server is not running. Call #start first." unless running?
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
def ensure_initialized
|
|
210
|
+
ensure_running
|
|
211
|
+
raise Error, "Not initialized. Call #initialize! first." unless @initialized
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
def write_line(msg)
|
|
215
|
+
@stdin&.puts(JSON.generate(msg))
|
|
216
|
+
@stdin&.flush
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
def handle_message(msg)
|
|
220
|
+
if msg.key?("id")
|
|
221
|
+
rid = msg["id"]
|
|
222
|
+
future = nil
|
|
223
|
+
@mutex.synchronize { future = @pending.delete(rid) }
|
|
224
|
+
|
|
225
|
+
if future
|
|
226
|
+
@mutex.synchronize do
|
|
227
|
+
if msg.key?("error")
|
|
228
|
+
future[:error] = Error.new("[#{msg["error"]["code"]}] #{msg["error"]["message"]}")
|
|
229
|
+
elsif msg.key?("result")
|
|
230
|
+
future[:result] = msg["result"]
|
|
231
|
+
else
|
|
232
|
+
future[:error] = Error.new("No result or error")
|
|
233
|
+
end
|
|
234
|
+
future[:done] = true
|
|
235
|
+
future[:condition].signal
|
|
236
|
+
end
|
|
237
|
+
return
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
dispatch_notification(msg["method"], msg["params"] || {}, msg["id"]) if msg.key?("method")
|
|
241
|
+
return
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
dispatch_notification(msg["method"], msg["params"] || {}, nil) if msg.key?("method")
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
def dispatch_notification(method, params, request_id)
|
|
248
|
+
@event_handlers.dup.each { |h| h.call(method, params, request_id) rescue nil }
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
def self.find_in_path(executable)
|
|
252
|
+
ENV["PATH"].to_s.split(File::PATH_SEPARATOR).each do |dir|
|
|
253
|
+
path = File.join(dir, executable)
|
|
254
|
+
return path if File.exist?(path)
|
|
255
|
+
end
|
|
256
|
+
nil
|
|
257
|
+
end
|
|
258
|
+
end
|
|
259
|
+
end
|
|
260
|
+
end
|
|
261
|
+
end
|