ask-coding-harness 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/CHANGELOG.md +42 -0
- data/LICENSE +21 -0
- data/README.md +120 -0
- data/bin/ach +7 -0
- data/bin/ask-coding-harness +7 -0
- data/lib/ask/coding_harness/agent_runner.rb +260 -0
- data/lib/ask/coding_harness/cli.rb +151 -0
- data/lib/ask/coding_harness/config.rb +119 -0
- data/lib/ask/coding_harness/demo_adapter.rb +253 -0
- data/lib/ask/coding_harness/event_translator.rb +115 -0
- data/lib/ask/coding_harness/runner.rb +109 -0
- data/lib/ask/coding_harness/server.rb +319 -0
- data/lib/ask/coding_harness/store.rb +126 -0
- data/lib/ask/coding_harness/version.rb +7 -0
- data/lib/ask-coding-harness.rb +62 -0
- data/public/assets/index-Cs2ZprmP.js +2113 -0
- data/public/assets/index-D2px4HJ9.css +1 -0
- data/public/icon.svg +5 -0
- data/public/index.html +25 -0
- data/public/manifest.json +17 -0
- data/public/sw.js +49 -0
- metadata +308 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: bb36f1a55f6b263fdbffd97383c1a744d9b4864a74d04d3003270134437bc3fd
|
|
4
|
+
data.tar.gz: f2a6fd12ffb36d5fff6baf1d59151c913dd5596db22a5d6abd366494d57be328
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 37ba3d010ae2ca1f044e919b2e82f3fc55e82bf96d792c65896e5e4141a225cecbcdc8e82c07d06eca24b12089d4e624b679b2a8fc5bd42d149b7e3047e37333
|
|
7
|
+
data.tar.gz: 3cd3c97b80c739cd6b9a6182241083856d514a2aee9f1ee6c8758f48671847377efe19f67a3a232c4eb412c725b51b3b3acd0b9f2df59381177bdf21e211643b
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## [0.2.0] - 2026-08-10
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
|
|
7
|
+
- **Demo adapter and `ach demo`** — a scripted coding agent (todos, tool
|
|
8
|
+
calls, diffs, approval flow) for trying the harness without API keys.
|
|
9
|
+
- **`ach run --adapter NAME`** — pick the coding agent adapter for headless
|
|
10
|
+
runs (ask_agent, demo, acp, ...).
|
|
11
|
+
- **Approval-aware external adapters** — the configured approval mode
|
|
12
|
+
passes through to external coding-agent adapters (the demo adapter
|
|
13
|
+
auto-approves in headless mode).
|
|
14
|
+
- **Web frontend** — mobile-first PWA built on ask-ui-kit: live tool
|
|
15
|
+
cards, approvals, todos, plans, diff/terminal viewers, conversation
|
|
16
|
+
sidebar, settings; installable with service worker + manifest.
|
|
17
|
+
- **ask-ui-kit components** — `ask-diff`, `ask-tool-approval`,
|
|
18
|
+
`ask-todo-list`, `ask-plan`, `ask-terminal-output`.
|
|
19
|
+
|
|
20
|
+
### Fixed
|
|
21
|
+
|
|
22
|
+
- Server startup via Rackup (CLI now wires the server with its own config;
|
|
23
|
+
flags like `--adapter` previously lost to the module-global config).
|
|
24
|
+
- Frontend turn state: Svelte 5 `$state` does not proxy `Map` mutations —
|
|
25
|
+
tool cards now render by replacing the map immutably.
|
|
26
|
+
- Duplicate assistant message after turn completion (the finished turn is
|
|
27
|
+
folded into the message list on the next send).
|
|
28
|
+
|
|
29
|
+
## [0.1.0] - 2026-08-10
|
|
30
|
+
|
|
31
|
+
### Added
|
|
32
|
+
|
|
33
|
+
- **Initial release** — general-purpose coding agent in the browser.
|
|
34
|
+
- **Harness core** — config (approval modes, plan mode, todos), conversation
|
|
35
|
+
store on ask-state-providers, event translator normalizing adapter events
|
|
36
|
+
into one browser-friendly SSE schema, agent runner (ask-agent runtime via
|
|
37
|
+
ask-coding-providers, extensible to ACP/Codex/Claude adapters).
|
|
38
|
+
- **Web server** — Roda REST + SSE: conversations CRUD, streaming turns,
|
|
39
|
+
approvals, plan approve/reject, abort, SPA fallback, CORS.
|
|
40
|
+
- **CLI** — `ach serve`, `ach run` (headless dogfooding path), `ach
|
|
41
|
+
sessions`, `ach version`; `ask-coding-harness` executable.
|
|
42
|
+
- **Renamed from askoda** — full git history preserved.
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Kaka Ruto
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
# ask-coding-harness
|
|
2
|
+
|
|
3
|
+
**A general-purpose coding agent in the browser.** Self-hosted, mobile-first,
|
|
4
|
+
and built on the ask-rb ecosystem. Point it at any project directory and let
|
|
5
|
+
the agent fire away — reading, writing, and running commands while you watch
|
|
6
|
+
every tool call, approve what needs approving, and review the diffs.
|
|
7
|
+
|
|
8
|
+
## Quick start
|
|
9
|
+
|
|
10
|
+
```bash
|
|
11
|
+
gem install ask-coding-harness
|
|
12
|
+
|
|
13
|
+
cd /path/to/your/project
|
|
14
|
+
ask-coding-harness # or: ach serve
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
Open http://localhost:8080. Set a model via `ACH_MODEL` (or
|
|
18
|
+
`ASK_AGENT_MODEL`) and an API key for the provider (e.g. `OPENCODE_API_KEY`).
|
|
19
|
+
|
|
20
|
+
**No API key yet?** Try the scripted demo agent:
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
ach demo
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
## What you get
|
|
27
|
+
|
|
28
|
+
- **The agent, unleashed** — `ask-agent` runs the loop in-process with the
|
|
29
|
+
shell toolset (`bash`, `read`, `write`, `edit`, `grep`, `glob`, `code`,
|
|
30
|
+
`apply_patch`), all routed through `ask-sandbox-providers`.
|
|
31
|
+
- **Live event stream** — text deltas, thinking, tool calls, todos, and
|
|
32
|
+
plan proposals stream to the browser over SSE as they happen.
|
|
33
|
+
- **Approvals** — mutating tools (`bash`, `write`, `edit`, ...) queue for
|
|
34
|
+
your approval by default; approve, reject, or approve-all from the UI.
|
|
35
|
+
- **Plan mode** — opt in with `ACH_PLAN_MODE=1`: the agent researches
|
|
36
|
+
read-only, proposes a plan, and only executes after you approve it.
|
|
37
|
+
- **Conversations** — saved to SQLite, resumable, renameable, archivable.
|
|
38
|
+
- **PWA** — installable on desktop and mobile, works offline for the shell
|
|
39
|
+
(the agent itself needs the server).
|
|
40
|
+
- **Extensible** — other coding agents (Codex, Claude Code, ACP-based)
|
|
41
|
+
plug in via `ask-coding-providers` by setting `ACH_ADAPTER=acp`.
|
|
42
|
+
|
|
43
|
+
## The `ach` CLI
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
ach serve # web server (default command)
|
|
47
|
+
ach demo # scripted agent — no API key needed
|
|
48
|
+
ach run "refactor the auth flow" # headless run, prints a transcript
|
|
49
|
+
ach sessions # list saved conversations
|
|
50
|
+
ach version
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
`ach run` is the dogfooding path: ask-coding-harness builds itself with it.
|
|
54
|
+
|
|
55
|
+
## Configuration
|
|
56
|
+
|
|
57
|
+
| Env var | Default | Purpose |
|
|
58
|
+
|---|---|---|
|
|
59
|
+
| `ACH_WORKSPACE` | current dir | project the agent operates on |
|
|
60
|
+
| `ACH_HOST` / `ACH_PORT` | `0.0.0.0` / `8080` | server bind |
|
|
61
|
+
| `ACH_MODEL` | `deepseek-v4-flash` | default model |
|
|
62
|
+
| `ACH_ADAPTER` | `ask_agent` | coding agent adapter (`ask_agent`, `acp`, ...) |
|
|
63
|
+
| `ACH_APPROVAL` | `require` | `off`, `require`, `auto` |
|
|
64
|
+
| `ACH_PLAN_MODE` | off | plan mode (research first, then execute) |
|
|
65
|
+
| `ACH_TODOS` | on | todo list tool |
|
|
66
|
+
| `ACH_DB_PATH` | `./data/ask-coding-harness.db` | conversation database |
|
|
67
|
+
| `ACH_SYSTEM_PROMPT` | — | extra system prompt lines |
|
|
68
|
+
|
|
69
|
+
Programmatic use:
|
|
70
|
+
|
|
71
|
+
```ruby
|
|
72
|
+
require "ask-coding-harness"
|
|
73
|
+
|
|
74
|
+
Ask::CodingHarness.configure do |c|
|
|
75
|
+
c.workspace = "/path/to/project"
|
|
76
|
+
c.model = "claude-sonnet-4"
|
|
77
|
+
c.approval = :require
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
result = Ask::CodingHarness.run("Run the test suite and fix failures")
|
|
81
|
+
puts result.response
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
## Architecture
|
|
85
|
+
|
|
86
|
+
```
|
|
87
|
+
Browser (PWA) ──SSE──> Server (Roda) ──> AgentRunner ──> ask-coding-providers
|
|
88
|
+
├─ ask_agent (default)
|
|
89
|
+
└─ acp / codex / claude
|
|
90
|
+
└── SQLite (ask-state-providers) <── Store
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
The server is a thin shell: conversations live in the Store, turns run in
|
|
94
|
+
the AgentRunner, adapter events normalize through the EventTranslator into
|
|
95
|
+
one browser-friendly schema.
|
|
96
|
+
|
|
97
|
+
## Development
|
|
98
|
+
|
|
99
|
+
```bash
|
|
100
|
+
bundle install
|
|
101
|
+
bundle exec rake test # Ruby tests
|
|
102
|
+
(cd web && npm install && npm run build) # build the PWA into public/
|
|
103
|
+
(cd web && npm test) # frontend unit tests
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
The frontend imports `ask-ui-kit` from source (a sibling repo, via a vite
|
|
107
|
+
alias) — keep `ask-ui-kit` next to this repo and rebuild it after changing
|
|
108
|
+
components.
|
|
109
|
+
|
|
110
|
+
## The ask-rb ecosystem
|
|
111
|
+
|
|
112
|
+
ask-coding-harness is part of the ask-rb ecosystem: `ask-agent` runs the
|
|
113
|
+
loop, `ask-coding-providers` supplies the adapters, `ask-tools-shell` the
|
|
114
|
+
tools (sandboxed via `ask-sandbox-providers`), `ask-state-providers` the
|
|
115
|
+
storage, and `ask-ui-kit` the reusable web components. See
|
|
116
|
+
https://ask-rb.github.io/ask-docs for the full picture.
|
|
117
|
+
|
|
118
|
+
## License
|
|
119
|
+
|
|
120
|
+
MIT — see [LICENSE](LICENSE).
|
data/bin/ach
ADDED
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "thread"
|
|
4
|
+
|
|
5
|
+
module Ask
|
|
6
|
+
module CodingHarness
|
|
7
|
+
# Runs coding-agent turns for conversations: maps each conversation to
|
|
8
|
+
# an adapter session, streams translated events, and persists assistant
|
|
9
|
+
# messages back into the store.
|
|
10
|
+
#
|
|
11
|
+
# The default runtime is ask-agent (via the ask_agent adapter). Other
|
|
12
|
+
# coding agents (Codex, Claude Code, ACP-based agents) plug in through
|
|
13
|
+
# ask-coding-providers by setting ACH_ADAPTER/CODING_PROVIDER — the
|
|
14
|
+
# runner only needs the adapter's duck-typed surface:
|
|
15
|
+
# create_session, send_and_stream, and (when supported) approve_action,
|
|
16
|
+
# reject_action, approve_all, approve_plan, reject_plan, abort.
|
|
17
|
+
class AgentRunner
|
|
18
|
+
# Tool name → ask-tools-shell class, for the ask_agent runtime.
|
|
19
|
+
TOOL_CLASSES = {
|
|
20
|
+
"bash" => Ask::Tools::Bash,
|
|
21
|
+
"read" => Ask::Tools::Read,
|
|
22
|
+
"write" => Ask::Tools::Write,
|
|
23
|
+
"edit" => Ask::Tools::Edit,
|
|
24
|
+
"glob" => Ask::Tools::Glob,
|
|
25
|
+
"grep" => Ask::Tools::Grep,
|
|
26
|
+
"code" => Ask::Tools::Code,
|
|
27
|
+
"apply_patch" => Ask::Tools::ApplyPatch,
|
|
28
|
+
"repl" => Ask::Tools::Repl
|
|
29
|
+
}.freeze
|
|
30
|
+
|
|
31
|
+
# @param config [Config] harness configuration
|
|
32
|
+
# @param store [Store] conversation persistence
|
|
33
|
+
# @param translator [EventTranslator] adapter event → SSE event
|
|
34
|
+
# @param tool_classes [Hash{String => Class}] tool name → class map;
|
|
35
|
+
# overridable so hosts can register custom tools
|
|
36
|
+
def initialize(config:, store:, translator: EventTranslator.new, tool_classes: TOOL_CLASSES)
|
|
37
|
+
@config = config
|
|
38
|
+
@store = store
|
|
39
|
+
@translator = translator
|
|
40
|
+
@tool_classes = tool_classes
|
|
41
|
+
@adapter = nil
|
|
42
|
+
@mutex = Mutex.new
|
|
43
|
+
@adapter_mutex = Mutex.new
|
|
44
|
+
@sessions = {}
|
|
45
|
+
@turns = {}
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# The adapter (created lazily on first use). Uses its own mutex so
|
|
49
|
+
# callers holding @mutex (session_for, stop) can still build it.
|
|
50
|
+
def adapter
|
|
51
|
+
@adapter_mutex.synchronize { @adapter ||= build_adapter }
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Run a turn for a conversation, streaming harness events to the
|
|
55
|
+
# block. Runs in a background thread; returns the thread.
|
|
56
|
+
#
|
|
57
|
+
# @param conversation [Hash] conversation record (from the store)
|
|
58
|
+
# @param prompt [String] the user message
|
|
59
|
+
# @param model [String, nil] model override
|
|
60
|
+
# @yield [Hash] harness events ({ type:, data: })
|
|
61
|
+
# @return [Thread]
|
|
62
|
+
def start_turn(conversation, prompt, model: nil, &on_event)
|
|
63
|
+
raise ArgumentError, "prompt must not be empty" if prompt.to_s.strip.empty?
|
|
64
|
+
|
|
65
|
+
thread = Thread.new do
|
|
66
|
+
begin
|
|
67
|
+
run_turn(conversation, prompt, model: model, &on_event)
|
|
68
|
+
rescue => e
|
|
69
|
+
emit(on_event, { type: "error", data: { error: e.message } })
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
@mutex.synchronize { @turns[conversation["id"]] = thread }
|
|
73
|
+
thread
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def running?(conversation_id)
|
|
77
|
+
thread = @mutex.synchronize { @turns[conversation_id] }
|
|
78
|
+
thread&.alive? ? true : false
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# ── Approval / plan controls (no-ops when the adapter lacks them) ──
|
|
82
|
+
|
|
83
|
+
def approve(conversation_id, action_id)
|
|
84
|
+
send_adapter(conversation_id, :approve_action, action_id)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def reject(conversation_id, action_id)
|
|
88
|
+
send_adapter(conversation_id, :reject_action, action_id)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def approve_all(conversation_id)
|
|
92
|
+
send_adapter(conversation_id, :approve_all)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def approve_plan(conversation_id)
|
|
96
|
+
send_adapter(conversation_id, :approve_plan)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def reject_plan(conversation_id)
|
|
100
|
+
send_adapter(conversation_id, :reject_plan)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def abort(conversation_id)
|
|
104
|
+
send_adapter(conversation_id, :abort)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def pending_approvals(conversation_id)
|
|
108
|
+
sid = @mutex.synchronize { @sessions[conversation_id] }
|
|
109
|
+
return [] unless sid
|
|
110
|
+
if adapter.respond_to?(:pending_approvals)
|
|
111
|
+
adapter.pending_approvals(sid)
|
|
112
|
+
else
|
|
113
|
+
[]
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def stop
|
|
118
|
+
@mutex.synchronize do
|
|
119
|
+
@turns.each_value { |t| t.kill if t.alive? }
|
|
120
|
+
@turns.clear
|
|
121
|
+
@sessions.clear
|
|
122
|
+
@adapter&.stop if @adapter.respond_to?(:stop)
|
|
123
|
+
@adapter = nil
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
private
|
|
128
|
+
|
|
129
|
+
def run_turn(conversation, prompt, model: nil, &on_event)
|
|
130
|
+
sid = session_for(conversation, model: model)
|
|
131
|
+
|
|
132
|
+
conv = @store.load(conversation["id"]) || conversation
|
|
133
|
+
conv["messages"] << { "role" => "user", "content" => prompt, "created_at" => Time.now.iso8601 }
|
|
134
|
+
@store.save(conv)
|
|
135
|
+
|
|
136
|
+
outcome = :completed
|
|
137
|
+
accumulated = +""
|
|
138
|
+
adapter.send_and_stream(sid, prompt, turn_timeout: @config.turn_timeout) do |event|
|
|
139
|
+
translated = @translator.translate(event)
|
|
140
|
+
next unless translated
|
|
141
|
+
|
|
142
|
+
case translated[:type]
|
|
143
|
+
when "message.delta"
|
|
144
|
+
accumulated << translated[:data][:delta].to_s
|
|
145
|
+
when "turn.failed"
|
|
146
|
+
outcome = :failed
|
|
147
|
+
when "turn.aborted"
|
|
148
|
+
outcome = :aborted
|
|
149
|
+
end
|
|
150
|
+
emit(on_event, translated)
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
persist_outcome(conversation["id"], accumulated, outcome)
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# Persist the assistant turn outcome (response, error, or abort) so
|
|
157
|
+
# the conversation reads correctly when reopened. Best-effort; the
|
|
158
|
+
# streamed events are the source of truth for the live view.
|
|
159
|
+
def persist_outcome(conversation_id, accumulated, outcome)
|
|
160
|
+
conv = @store.load(conversation_id)
|
|
161
|
+
return unless conv
|
|
162
|
+
# Only persist when the last message is still the user's prompt.
|
|
163
|
+
last = conv["messages"].last
|
|
164
|
+
return if last && last["role"] == "assistant"
|
|
165
|
+
|
|
166
|
+
content =
|
|
167
|
+
case outcome
|
|
168
|
+
when :aborted then "Turn aborted."
|
|
169
|
+
when :failed then accumulated.empty? ? "The turn failed." : accumulated
|
|
170
|
+
else accumulated
|
|
171
|
+
end
|
|
172
|
+
conv["messages"] << { "role" => "assistant", "content" => content, "created_at" => Time.now.iso8601 }
|
|
173
|
+
@store.save(conv)
|
|
174
|
+
rescue StandardError
|
|
175
|
+
nil
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
# One adapter session per conversation, created on first use.
|
|
179
|
+
def session_for(conversation, model: nil)
|
|
180
|
+
@mutex.synchronize do
|
|
181
|
+
@sessions[conversation["id"]] ||= adapter.create_session(
|
|
182
|
+
conversation["directory"] || @config.workspace,
|
|
183
|
+
model: model || @config.model
|
|
184
|
+
)
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
# Dispatch a control to the adapter, returning nil when the adapter
|
|
189
|
+
# doesn't support it (e.g. external ACP agents without approvals).
|
|
190
|
+
def send_adapter(conversation_id, method_name, *args)
|
|
191
|
+
sid = @mutex.synchronize { @sessions[conversation_id] }
|
|
192
|
+
return nil unless sid
|
|
193
|
+
return nil unless adapter.respond_to?(method_name)
|
|
194
|
+
adapter.public_send(method_name, sid, *args)
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
def emit(on_event, event)
|
|
198
|
+
on_event&.call(event)
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
def build_adapter
|
|
202
|
+
case @config.adapter.to_s
|
|
203
|
+
when "ask_agent"
|
|
204
|
+
build_ask_agent_adapter
|
|
205
|
+
else
|
|
206
|
+
build_external_adapter
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
def build_ask_agent_adapter
|
|
211
|
+
require "ask-coding-providers"
|
|
212
|
+
adapter = Ask::CodingProviders::AskAgent::Adapter.new(
|
|
213
|
+
model: @config.model,
|
|
214
|
+
provider: ENV.fetch("ASK_AGENT_LLM_PROVIDER", "opencode_go"),
|
|
215
|
+
tools: resolve_tools,
|
|
216
|
+
max_turns: @config.max_turns,
|
|
217
|
+
approval: @config.approval,
|
|
218
|
+
approval_required: @config.approval_policy_tools,
|
|
219
|
+
plan_mode: @config.plan_mode,
|
|
220
|
+
todos: @config.todos,
|
|
221
|
+
system_prompt: default_system_prompt
|
|
222
|
+
)
|
|
223
|
+
adapter.start
|
|
224
|
+
# Tools operate relative to the process working directory; the
|
|
225
|
+
# workspace IS the working directory for this server.
|
|
226
|
+
Dir.chdir(@config.workspace) if File.directory?(@config.workspace)
|
|
227
|
+
adapter
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
def build_external_adapter
|
|
231
|
+
require "ask/coding_providers"
|
|
232
|
+
adapter = Ask::CodingProviders.build_adapter(
|
|
233
|
+
@config.adapter,
|
|
234
|
+
workspace_path: @config.workspace,
|
|
235
|
+
turn_timeout: @config.turn_timeout,
|
|
236
|
+
approval: @config.approval
|
|
237
|
+
)
|
|
238
|
+
adapter.start if adapter.respond_to?(:start)
|
|
239
|
+
adapter
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
def resolve_tools
|
|
243
|
+
@config.tools.filter_map do |name|
|
|
244
|
+
klass = @tool_classes[name.to_s]
|
|
245
|
+
raise ArgumentError, "Unknown tool: #{name}" unless klass
|
|
246
|
+
klass.new
|
|
247
|
+
end
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
def default_system_prompt
|
|
251
|
+
base = "You are the coding agent for the workspace \"#{File.basename(@config.workspace)}\" " \
|
|
252
|
+
"at #{@config.workspace}. You can read, write, and edit files, search the codebase, " \
|
|
253
|
+
"and run shell commands to inspect and modify the project. Work autonomously: " \
|
|
254
|
+
"investigate, make changes, and verify your work."
|
|
255
|
+
extra = ENV["ACH_SYSTEM_PROMPT"]
|
|
256
|
+
extra ? "#{base}\n\n#{extra}" : base
|
|
257
|
+
end
|
|
258
|
+
end
|
|
259
|
+
end
|
|
260
|
+
end
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "optparse"
|
|
4
|
+
|
|
5
|
+
module Ask
|
|
6
|
+
module CodingHarness
|
|
7
|
+
# Command-line interface for the coding harness.
|
|
8
|
+
#
|
|
9
|
+
# ach serve Start the web server (default)
|
|
10
|
+
# ach run "task" Run a prompt headlessly against the workspace
|
|
11
|
+
# ach sessions List saved conversations
|
|
12
|
+
# ach version Print the version
|
|
13
|
+
# ach help Show help
|
|
14
|
+
#
|
|
15
|
+
# The `ask-coding-harness` executable runs `serve` by default.
|
|
16
|
+
class CLI
|
|
17
|
+
def self.run(argv, stdout: $stdout, stderr: $stderr)
|
|
18
|
+
new(argv, stdout: stdout, stderr: stderr).run
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def initialize(argv, stdout: $stdout, stderr: $stderr)
|
|
22
|
+
@argv = argv.dup
|
|
23
|
+
@stdout = stdout
|
|
24
|
+
@stderr = stderr
|
|
25
|
+
# Fresh config per invocation: the CLI is a process boundary and
|
|
26
|
+
# must honor the environment at launch, not a memoized global.
|
|
27
|
+
@config = Ask::CodingHarness::Config.new
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def run
|
|
31
|
+
command = @argv.shift || "serve"
|
|
32
|
+
case command
|
|
33
|
+
when "serve", "server" then serve
|
|
34
|
+
when "demo" then serve(demo: true)
|
|
35
|
+
when "run" then run_headless
|
|
36
|
+
when "sessions" then list_sessions
|
|
37
|
+
when "version", "--version", "-v" then @stdout.puts Ask::CodingHarness::VERSION
|
|
38
|
+
when "help", "--help", "-h" then help
|
|
39
|
+
else
|
|
40
|
+
@stderr.puts "Unknown command: #{command}"
|
|
41
|
+
help
|
|
42
|
+
exit 1
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
private
|
|
47
|
+
|
|
48
|
+
def serve(demo: false)
|
|
49
|
+
if demo
|
|
50
|
+
@config.adapter = "demo"
|
|
51
|
+
require "ask/coding_harness/demo_adapter"
|
|
52
|
+
@stdout.puts "demo mode: scripted agent, no API key needed"
|
|
53
|
+
end
|
|
54
|
+
options = { host: @config.host, port: @config.port }
|
|
55
|
+
parser = OptionParser.new do |o|
|
|
56
|
+
o.banner = "Usage: ach serve [options]"
|
|
57
|
+
o.on("-H", "--host HOST", "Bind host (default #{@config.host})") { |v| options[:host] = v }
|
|
58
|
+
o.on("-p", "--port PORT", Integer, "Port (default #{@config.port})") { |v| options[:port] = v }
|
|
59
|
+
o.on("-w", "--workspace DIR", "Workspace directory (default #{@config.workspace})") { |v| @config.workspace = v }
|
|
60
|
+
o.on("--model MODEL", "Default model") { |v| @config.model = v }
|
|
61
|
+
o.on("--approval MODE", "off|require|auto") { |v| @config.approval = v.to_sym }
|
|
62
|
+
o.on("--plan-mode", "Enable plan mode") { @config.plan_mode = true }
|
|
63
|
+
o.on("--no-todos", "Disable the todo list") { @config.todos = false }
|
|
64
|
+
o.on("-h", "--help", "Show help") { puts o; return }
|
|
65
|
+
end
|
|
66
|
+
parser.parse!(@argv)
|
|
67
|
+
|
|
68
|
+
require "ask/coding_harness/server"
|
|
69
|
+
require "rackup"
|
|
70
|
+
@stdout.puts "ask-coding-harness #{Ask::CodingHarness::VERSION}"
|
|
71
|
+
@stdout.puts "workspace: #{@config.workspace}"
|
|
72
|
+
@stdout.puts "model: #{@config.model}"
|
|
73
|
+
@stdout.puts "approval: #{@config.approval}"
|
|
74
|
+
@stdout.puts "listening: http://#{options[:host]}:#{options[:port]}"
|
|
75
|
+
store = Store.new(db_path: @config.db_path)
|
|
76
|
+
runner = AgentRunner.new(config: @config, store: store)
|
|
77
|
+
app = Server.build(config: @config, store: store, runner: runner).freeze.app
|
|
78
|
+
Rackup::Server.start(app: app, Host: options[:host], Port: options[:port])
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def run_headless
|
|
82
|
+
options = { quiet: false, model: nil, workspace: nil, approval: :off, adapter: nil }
|
|
83
|
+
parser = OptionParser.new do |o|
|
|
84
|
+
o.banner = "Usage: ach run [options] \"prompt\""
|
|
85
|
+
o.on("-w", "--workspace DIR", "Workspace directory") { |v| options[:workspace] = v }
|
|
86
|
+
o.on("--model MODEL", "Model to use") { |v| options[:model] = v }
|
|
87
|
+
o.on("--adapter NAME", "Coding agent adapter (ask_agent, demo, acp, ...)") { |v| options[:adapter] = v }
|
|
88
|
+
o.on("--approval MODE", "off|require|auto (default off)") { |v| options[:approval] = v.to_sym }
|
|
89
|
+
o.on("-q", "--quiet", "Suppress the transcript") { options[:quiet] = true }
|
|
90
|
+
o.on("-h", "--help", "Show help") { puts o; return }
|
|
91
|
+
end
|
|
92
|
+
parser.parse!(@argv)
|
|
93
|
+
if options[:adapter] == "demo"
|
|
94
|
+
require "ask/coding_harness/demo_adapter"
|
|
95
|
+
end
|
|
96
|
+
@config.adapter = options[:adapter] if options[:adapter]
|
|
97
|
+
|
|
98
|
+
prompt = @argv.join(" ").strip
|
|
99
|
+
if prompt.empty?
|
|
100
|
+
@stderr.puts "Error: a prompt is required"
|
|
101
|
+
@stderr.puts "Usage: ach run [options] \"prompt\""
|
|
102
|
+
exit 1
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
@config.workspace = options[:workspace] if options[:workspace]
|
|
106
|
+
require "ask/coding_harness/runner"
|
|
107
|
+
result = Runner.new(config: @config, approval: options[:approval]).run(
|
|
108
|
+
prompt, model: options[:model], quiet: options[:quiet]
|
|
109
|
+
)
|
|
110
|
+
exit(result.success? ? 0 : 1)
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def list_sessions
|
|
114
|
+
store = Store.new(db_path: @config.db_path)
|
|
115
|
+
conversations = store.list
|
|
116
|
+
if conversations.empty?
|
|
117
|
+
@stdout.puts "No conversations yet."
|
|
118
|
+
return
|
|
119
|
+
end
|
|
120
|
+
conversations.each do |c|
|
|
121
|
+
@stdout.puts "#{c["id"][0, 8]} #{c["title"]} (#{c["message_count"]} messages, #{c["updated_at"]})"
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def help
|
|
126
|
+
@stdout.puts <<~HELP
|
|
127
|
+
ask-coding-harness #{Ask::CodingHarness::VERSION} — general-purpose coding agent in the browser.
|
|
128
|
+
|
|
129
|
+
Usage:
|
|
130
|
+
ach serve Start the web server (default)
|
|
131
|
+
ach demo Serve with the scripted demo agent (no API key)
|
|
132
|
+
ach run "task" Run a prompt headlessly in the workspace
|
|
133
|
+
ach sessions List saved conversations
|
|
134
|
+
ach version Print the version
|
|
135
|
+
ach help Show this help
|
|
136
|
+
|
|
137
|
+
Environment:
|
|
138
|
+
ACH_WORKSPACE workspace directory (default: current dir)
|
|
139
|
+
ACH_HOST / ACH_PORT
|
|
140
|
+
ACH_MODEL default model
|
|
141
|
+
ACH_ADAPTER coding agent adapter: ask_agent (default) or any
|
|
142
|
+
ask-coding-providers adapter (acp, codex, claude)
|
|
143
|
+
ACH_APPROVAL off | require | auto (default: require)
|
|
144
|
+
ACH_PLAN_MODE enable plan mode (1/true)
|
|
145
|
+
ACH_TODOS todo list (default: on)
|
|
146
|
+
ACH_DB_PATH conversation database path
|
|
147
|
+
HELP
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
end
|