riffer-rig 0.11.0 → 0.12.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.
Files changed (44) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +11 -0
  3. data/README.md +94 -31
  4. data/lib/riffer/rig/acp/host.rb +83 -0
  5. data/lib/riffer/rig/acp.rb +353 -0
  6. data/lib/riffer/rig/bundled/mcp.rb +6 -1
  7. data/lib/riffer/rig/cli/flags.rb +50 -17
  8. data/lib/riffer/rig/cli/sessions.rb +124 -0
  9. data/lib/riffer/rig/cli.rb +16 -75
  10. data/lib/riffer/rig/headless/host.rb +9 -1
  11. data/lib/riffer/rig/headless/ndjson.rb +49 -0
  12. data/lib/riffer/rig/headless.rb +27 -12
  13. data/lib/riffer/rig/loader.rb +32 -3
  14. data/lib/riffer/rig/mcp/auth.rb +59 -0
  15. data/lib/riffer/rig/mcp/declaration.rb +29 -5
  16. data/lib/riffer/rig/registrar.rb +11 -3
  17. data/lib/riffer/rig/runtime.rb +35 -1
  18. data/lib/riffer/rig/terminal/picker.rb +144 -0
  19. data/lib/riffer/rig/terminal/session.rb +33 -0
  20. data/lib/riffer/rig/terminal.rb +113 -17
  21. data/lib/riffer/rig/version.rb +1 -1
  22. data/lib/riffer/rig.rb +1 -1
  23. data/sig/generated/riffer/rig/acp/host.rbs +53 -0
  24. data/sig/generated/riffer/rig/acp.rbs +169 -0
  25. data/sig/generated/riffer/rig/cli/flags.rbs +23 -2
  26. data/sig/generated/riffer/rig/cli/sessions.rbs +72 -0
  27. data/sig/generated/riffer/rig/cli.rbs +3 -31
  28. data/sig/generated/riffer/rig/headless/host.rbs +4 -1
  29. data/sig/generated/riffer/rig/headless/ndjson.rbs +27 -0
  30. data/sig/generated/riffer/rig/headless.rbs +11 -2
  31. data/sig/generated/riffer/rig/loader.rbs +15 -2
  32. data/sig/generated/riffer/rig/mcp/auth.rbs +32 -0
  33. data/sig/generated/riffer/rig/mcp/declaration.rbs +23 -4
  34. data/sig/generated/riffer/rig/registrar.rbs +6 -2
  35. data/sig/generated/riffer/rig/runtime.rbs +16 -3
  36. data/sig/generated/riffer/rig/terminal/picker.rbs +66 -0
  37. data/sig/generated/riffer/rig/terminal/session.rbs +32 -0
  38. data/sig/generated/riffer/rig/terminal.rbs +52 -8
  39. data/sig/manual/riffer/rig/events.rbs +1 -0
  40. data/sig/manual/riffer/rig/headless/ndjson.rbs +6 -0
  41. data/sig/manual/riffer/rig/headless.rbs +7 -0
  42. data/sig/manual/riffer/rig/mcp/auth.rbs +6 -0
  43. data/sig/manual/riffer/rig/terminal.rbs +12 -0
  44. metadata +33 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 5f79d98c522224290937a01b701fa7f4ac72f2be08fbbb39abf32f06691880f7
4
- data.tar.gz: 3c66dbd81e98e86b1c1132094431e0803d23c757a950684c6663ad5abd8c3369
3
+ metadata.gz: 04c27f5bf6e9339d06df9f64501e3b3dac5fa4724b0272a29641b0d9b707f788
4
+ data.tar.gz: a89ddd0ef08516f50c3ea42c2374ff7ff80ea78b55ba1ed8a561851df6280745
5
5
  SHA512:
6
- metadata.gz: 6087cb7527548ca826bf04ab3b214c36ab29e18c676c62f7df84377010bb47fddae9e88b1354d1fa8815efa4967d4599ccc63e022c57660a567b58e226554679
7
- data.tar.gz: 2aae83bea0978c35b33674149527c0281baeb33f7bf186ba38d9b6abdeb905a3649989eae97430d7eae6284a80a253152d0532c8ba3d75f3ae3d7adee6f6a757
6
+ metadata.gz: 11115efca8ca651cf9d1211c0c17e112e4369ce72f606c0c3886e1caae0c3a5b48b281f298a88481fdfff630eb57b19b2de2aa2dffbb9ef3e01a23fe0fa32a11
7
+ data.tar.gz: f0548a971011eb9b8439de4237bdeaf61803867a2a692f02cf37ea8f69e6ddb1f4492e5fb5e49fe81d95ac3b379909383ca15949822e5e50f2c39c4686cf77b7
data/CHANGELOG.md CHANGED
@@ -1,5 +1,16 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.12.0](https://github.com/bottrall/riffer-rig/compare/v0.11.0...v0.12.0) (2026-10-07)
4
+
5
+
6
+ ### Features
7
+
8
+ * --json streams every Runtime event as NDJSON ([#231](https://github.com/bottrall/riffer-rig/issues/231)) ([a5e4641](https://github.com/bottrall/riffer-rig/commit/a5e464172cb84fcbfc2fbce0a311c070b54a539a))
9
+ * /resume picker and /new in the terminal ([#232](https://github.com/bottrall/riffer-rig/issues/232)) ([140fbac](https://github.com/bottrall/riffer-rig/commit/140fbac7c7fc966987ee77c78222204bb755e37f))
10
+ * ACP session/load and session/list ([#237](https://github.com/bottrall/riffer-rig/issues/237)) ([db81553](https://github.com/bottrall/riffer-rig/commit/db815536fa940d3f14615fa55b27af63164665ce))
11
+ * resolve secret MCP headers from env and auth.json ([#234](https://github.com/bottrall/riffer-rig/issues/234)) ([6253f95](https://github.com/bottrall/riffer-rig/commit/6253f95932b81e276cec0850f7aa48abdb332b63))
12
+ * riffer acp serves the Runtime over ACP ([#235](https://github.com/bottrall/riffer-rig/issues/235)) ([9497122](https://github.com/bottrall/riffer-rig/commit/9497122cc0cdac3576481f6efeda6e49f595d999))
13
+
3
14
  ## [0.11.0](https://github.com/bottrall/riffer-rig/compare/v0.10.0...v0.11.0) (2026-10-06)
4
15
 
5
16
 
data/README.md CHANGED
@@ -1,8 +1,8 @@
1
1
  # riffer-rig
2
2
 
3
- A dead-simple terminal coding agent built on [riffer](https://github.com/janeapp/riffer).
3
+ **riffer, in your terminal and in your process.**
4
4
 
5
- `riffer-rig` is an interactive terminal coding agent with read, write, edit, and bash tools — point it at your project and chat with it from the command line.
5
+ `riffer-rig` is a general-purpose agent for Ruby developers who want to own their harness. It runs as a terminal agent and, because the runtime is separate from the terminal, it also runs inside any Ruby process: a Rails app, a script, a job. You extend it in Ruby: tools, commands, providers and hooks are ordinary Ruby classes on [riffer](https://github.com/janeapp/riffer)'s primitives, and the same objects work whether the host is the terminal or your app. It ships with a coding toolkit (read, write, edit, bash) as the default bundle, because a shell and file access are the most efficient way to get almost any task done — but the prompt gives it no coding identity. [Overview](docs/OVERVIEW.md) has the full thesis.
6
6
 
7
7
  ## Requirements
8
8
 
@@ -48,10 +48,10 @@ The model is the first of these that is set: `--model`, `RIFFER_MODEL`, `model`
48
48
  | `--max-steps N` | stops a turn after `N` model calls |
49
49
  | `--no-save` | does not save this session to the [store](docs/SESSIONS.md) |
50
50
  | `-c`, `--continue` | continues the most recent session in this directory ([Sessions](docs/SESSIONS.md#resuming)) |
51
- | `-r ID`, `--resume ID` | resumes the session with id `ID` ([Sessions](docs/SESSIONS.md#resuming)) |
51
+ | `-r ID`, `--resume ID` | resumes the session with id `ID`; bare `-r` opens the [picker](docs/SESSIONS.md#the-picker) ([Sessions](docs/SESSIONS.md#resuming)) |
52
52
  | `-h`, `--help` | prints the usage |
53
53
 
54
- `riffer acp` (an editor's agent over stdio) is reserved: for now it prints the usage and exits with status 2.
54
+ An editor registers the agent as `riffer acp`, the same Runtimes over the [Agent Client Protocol](https://agentclientprotocol.com) on stdio ([ACP](docs/ACP.md)).
55
55
 
56
56
  ### Headless
57
57
 
@@ -63,7 +63,7 @@ git diff | riffer -p "review this diff" # piped stdin appended as context
63
63
  echo "explain this diff" | riffer -p # piped stdin alone is the prompt
64
64
  ```
65
65
 
66
- Everything loads exactly as the REPL does, the shared flags work the same — `-c` and `-r` included: `riffer -p -c "now run the tests"` continues the most recent session in this directory, and a missing session exits 2 ([Headless](docs/HEADLESS.md)). The session is saved like any other unless `--no-save`. Only the assistant's text is printed, streamed to stdout; errors and warnings go to stderr, and `--verbose` adds a one-line trace of each tool call there. Headless never prompts: a missing model, credential, or SDK prints the reason on stderr and exits 2.
66
+ Everything loads exactly as the REPL does, the shared flags work the same — `-c` and `-r` included: `riffer -p -c "now run the tests"` continues the most recent session in this directory, and a missing session exits 2 ([Headless](docs/HEADLESS.md)). The session is saved like any other unless `--no-save`. Only the assistant's text is printed, streamed to stdout; errors and warnings go to stderr, and `--verbose` adds a one-line trace of each tool call there. With `--json`, stdout is instead one JSON object per line for every Runtime event — the NDJSON stream ([Headless](docs/HEADLESS.md#the-ndjson-stream---json)). Headless never prompts: a missing model, credential, or SDK prints the reason on stderr and exits 2.
67
67
 
68
68
  The exit code says how the turn ended: `0` finished, `1` runtime error, `2` usage or configuration, `3` the turn ended without completing (`--max-steps`, a full context window, a provider content filter), `130` SIGINT (the turn is cancelled first).
69
69
 
@@ -73,10 +73,15 @@ Type a prompt and press Enter; the reply streams in, with each tool call and the
73
73
 
74
74
  - Ctrl-C during a turn cancels it and returns to the prompt. Ctrl-C at the prompt asks for a second one, which exits.
75
75
  - `/exit` or `/quit` ends the session, as does Ctrl-D.
76
+ - `/resume [--all]` opens the [session picker](docs/SESSIONS.md#the-picker): type to filter, switch with Enter on a single match or a row number, and delete with Ctrl-D and a confirm. `/new` starts a fresh session in place; the one you left stays on disk. Both replace the session the terminal drives and re-render the banner.
76
77
  - `/model provider/name` switches the model ([below](#switching-the-model)), and `/skill:<name> [text]` runs a skill ([Skills](docs/SKILLS.md)).
77
78
  - Any other `/name args` runs the Runtime command of that name, such as one an extension registers ([Extensions](docs/EXTENSIONS.md)); an unknown name is reported and nothing is sent to the model.
78
79
 
79
- ### Authentication
80
+ ### Switching the model
81
+
82
+ `/model provider/name` switches the model for the current session only, keeping the conversation so far — `/model openai/gpt-5`, say. The provider prefix is required: a bare name such as `/model sonnet` is rejected with the list of providers. `/model` alone shows the model in use. Switching runs the same setup for the new provider as startup does: its SDK gem is offered for install (or refused with the exact Gemfile line), and missing credentials are resolved — you are prompted for them when the host can ask, and the values apply to the running session immediately. The switch is refused with one error when the host cannot supply what is missing, and the model is left unchanged. `/model --save` writes the model in effect to the home `settings.json`, so new sessions start with it; `/model provider/name --save` switches and saves in one step, and a refused switch saves nothing ([Configuration](docs/CONFIGURATION.md#model)).
83
+
84
+ ## Authentication
80
85
 
81
86
  `riffer-rig` talks to the provider named by the model's prefix — `anthropic/claude-sonnet-4-6` means Anthropic. Provide that provider's credentials in either of two ways:
82
87
 
@@ -107,33 +112,92 @@ The flat `{"anthropic": "sk-…"}` shape earlier versions wrote is no longer rea
107
112
 
108
113
  Inside a session, `/auth` lists every provider with where its secret comes from — `env`, `stored`, `chain` (the SDK's own credential chain, for Bedrock) or `missing`. `/auth <provider>` re-runs that provider's setup, which is how you rotate a key, and applies the new values to the current session, so the next request uses them. `/auth remove <provider>` deletes the provider's `auth.json` entry and its `providers` block in settings.
109
114
 
110
- ### Switching the model
115
+ ## Configuration
111
116
 
112
- `/model provider/name` switches the model for the current session only, keeping the conversation so far — `/model openai/gpt-5`, say. The provider prefix is required: a bare name such as `/model sonnet` is rejected with the list of providers. `/model` alone shows the model in use. Switching runs the same setup for the new provider as startup does: its SDK gem is offered for install (or refused with the exact Gemfile line), and missing credentials are resolved — you are prompted for them when the host can ask, and the values apply to the running session immediately. The switch is refused with one error when the host cannot supply what is missing, and the model is left unchanged. `/model --save` writes the model in effect to the home `settings.json`, so new sessions start with it; `/model provider/name --save` switches and saves in one step, and a refused switch saves nothing ([Configuration](docs/CONFIGURATION.md#model)).
117
+ Settings live in two scopes, both optional: `~/.riffer/settings.json` for every project, and `<cwd>/.riffer/settings.json` for one project, merged key by key with the project winning ([Configuration](docs/CONFIGURATION.md)). Every core key is optional:
113
118
 
114
- ### Configuration
119
+ | Key | What it sets |
120
+ | ------------ | -------------------------------------------------------------------------------- |
121
+ | `model` | the model new sessions start with, as `provider/name` |
122
+ | `reasoning` | the reasoning effort, translated to the provider's own parameter |
123
+ | `models` | prices each model in USD per million tokens |
124
+ | `providers` | non-secret provider fields such as an endpoint or region (home file only) |
125
+ | `extensions` | `disabled` — bundled extensions to leave out; `autoload` — gem extension autoload |
126
+ | `reload` | `"auto"` (default) or `"manual"` — the hot-reload trigger |
127
+ | `sessions` | `save: false` stops saving sessions to the store |
128
+ | `tools` | the provider-native tool switches (`native`), off by default |
115
129
 
116
- - `AGENTS.md` — `~/.riffer/AGENTS.md` and an `AGENTS.md` in the current working directory or any directory above it, whichever exist, are re-read every turn as instructions that take precedence over the default norms ([Instructions](docs/INSTRUCTIONS.md#agentsmd)).
117
- - Skills — Agent Skills in `.agents/skills/` from the current working directory up to the repository root, and in `~/.agents/skills/`, are offered to the model, and each can be run with `/skill:<name>` ([Skills](docs/SKILLS.md)).
118
130
  - `RIFFER_MODEL` — the model for this run as `provider/name`, winning over the `model` setting. A bare name such as `sonnet` is rejected with the list of providers.
119
- - `~/.riffer/settings.json` — optional user settings, and `<cwd>/.riffer/settings.json` for one project, merged key by key with the project winning. Every key is optional:
120
-
121
- ```json
122
- {
123
- "model": "anthropic/claude-sonnet-4-6",
124
- "reasoning": "low",
125
- "models": {
126
- "anthropic/claude-sonnet-4-6": {
127
- "input": 3.0,
128
- "output": 15.0,
129
- "cache_write": 3.75,
130
- "cache_read": 0.3
131
- }
132
- }
133
- }
134
- ```
135
-
136
- `model` is the model new sessions start with; a host built on the [Loader](docs/EMBEDDING.md#building-a-runtime-with-the-loader) asks for one when none is set and writes the answer here. `extensions.disabled` (for example `["mcp"]`) leaves bundled extensions out. `models` prices each model in USD per million tokens; [Configuration](docs/CONFIGURATION.md) has every key. `reasoning` is translated to the provider's own parameter — Anthropic accepts `low`, `medium`, `high`, `xhigh` and `max`; OpenAI and OpenRouter accept `low`, `medium`, `high` and `xhigh`. Omitting it, or supplying an unrecognised value, leaves the model's default reasoning behaviour unchanged.
131
+ - `AGENTS.md` — `~/.riffer/AGENTS.md` and an `AGENTS.md` in the current working directory or any directory above it are re-read every turn as instructions that take precedence over the default norms ([Instructions](docs/INSTRUCTIONS.md#agentsmd)).
132
+ - Skills — Agent Skills in `.agents/skills/` from the working directory up to the repository root, and in `~/.agents/skills/`, are offered to the model, and `/skill:<name> [text]` runs one ([Skills](docs/SKILLS.md)).
133
+ - MCP servers — declared under the `mcp` key in either settings file and registered with the Runtime ([MCP](docs/MCP.md)).
134
+
135
+ ## Extending
136
+
137
+ An extension is a named registrar block in a `rig.rb` file — `~/.riffer/rig.rb` runs in every project, `<project>/.riffer/rig.rb` in one, and the first run of a project file asks you to trust it:
138
+
139
+ ```ruby
140
+ Riffer::Rig.extension('git') do |rig|
141
+ # A tool the model can call
142
+ rig.tool GitLog
143
+
144
+ # A /log command in the session
145
+ rig.command('log', description: 'Recent commits') do |ctx|
146
+ ctx.say `git log --oneline -n #{ctx.args}`
147
+ end
148
+
149
+ # A section appended to the system message, re-read every turn
150
+ rig.prompt(:branch) { |ctx| "Branch: #{`git branch --show-current`}" }
151
+ end
152
+ ```
153
+
154
+ Tools, commands, prompt sections, event handlers, skills, MCP servers and providers register the same way, and the same objects work whether the host is the terminal or your app. [Extensions](docs/EXTENSIONS.md) has all eight seams, error isolation, and packaging an extension as a gem.
155
+
156
+ ## Embedding
157
+
158
+ The runtime is separate from the terminal, so the same agent runs inside any Ruby process:
159
+
160
+ ```ruby
161
+ runtime = Riffer::Rig::Loader.runtime(cwd: Dir.pwd, host: Riffer::Rig::Hosts::Null.new)
162
+ response = runtime.ask('why is this test failing?')
163
+ puts response.content
164
+ ```
165
+
166
+ `Loader.runtime` applies the same filesystem conventions as the terminal — settings, credentials, `rig.rb`, skills. [Embedding](docs/EMBEDDING.md) has the streaming `prompt`, snapshots, and rebuilding after a code reload.
167
+
168
+ ## Documentation
169
+
170
+ The guides, in reading order:
171
+
172
+ Start here:
173
+
174
+ - [Overview](https://riffer.bottrall.dev/guides/overview/) — What riffer-rig is, the four tiers, the runtime and host layers
175
+ - [Getting started](https://riffer.bottrall.dev/guides/getting-started/) — Install, pick a model, first session
176
+
177
+ Using the terminal:
178
+
179
+ - [Configuration](https://riffer.bottrall.dev/guides/configuration/) — Settings scopes and every core key
180
+ - [Instructions](https://riffer.bottrall.dev/guides/instructions/) — How the system message is built; AGENTS.md
181
+ - [Tools](https://riffer.bottrall.dev/guides/tools/) — The bundled toolkit and provider-native tools
182
+ - [Skills](https://riffer.bottrall.dev/guides/skills/) — Agent Skills directories and activation
183
+ - [MCP](https://riffer.bottrall.dev/guides/mcp/) — Declaring MCP servers
184
+ - [Sessions](https://riffer.bottrall.dev/guides/sessions/) — Saving, resuming, the picker, the JSONL store
185
+ - [Reloading](https://riffer.bottrall.dev/guides/reloading/) — The /reload command, what reloads and what does not
186
+ - [Headless](https://riffer.bottrall.dev/guides/headless/) — riffer -p, NDJSON, exit codes
187
+ - [ACP](https://riffer.bottrall.dev/guides/acp/) — riffer acp, the ACP agent over stdio
188
+
189
+ Extending and embedding:
190
+
191
+ - [Extensions](https://riffer.bottrall.dev/guides/extensions/) — rig.rb, the registrar, the eight seams, commands, handlers, gems
192
+ - [Embedding](https://riffer.bottrall.dev/guides/embedding/) — Runtime and Loader from Ruby; snapshots; rebuild
193
+ - [Hosts](https://riffer.bottrall.dev/guides/hosts/) — The Host interface and writing a host
194
+
195
+ Providers:
196
+
197
+ - [Providers](https://riffer.bottrall.dev/guides/providers/overview/) — Model strings, precedence, credentials, /auth
198
+ - [Custom providers](https://riffer.bottrall.dev/guides/providers/custom/) — Registering a provider from an extension; the setup, credentials, listings
199
+
200
+ The guide sources are in `docs/`. To preview the site locally, run `bin/docs serve` and open <http://localhost:8000>.
137
201
 
138
202
  ## Development
139
203
 
@@ -150,7 +214,6 @@ Every project chore is a script in `bin/`. The Rakefile behind them is an implem
150
214
  | `bin/ci` | Run everything CI runs, serially. Use before pushing |
151
215
  | `bin/build` | Build the gem into `pkg/`; the publish workflow runs this before `gem push` |
152
216
  | `bin/docs` | Build the docs site and API reference into `_site/`; `bin/docs serve` serves it at http://localhost:8000 |
153
- | `bin/plans` | Serve the building plans in `plans/` at http://localhost:8001 |
154
217
 
155
218
  ## Releasing
156
219
 
@@ -174,4 +237,4 @@ Licensed under the MIT License. See [`LICENSE.txt`](LICENSE.txt) for details.
174
237
 
175
238
  ## Maintainer
176
239
 
177
- - Jake Bottrall - https://github.com/bottrall
240
+ - Jake Bottrall - https://github.com/bottrall
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'acp/sdk'
4
+
5
+ class Riffer::Rig::ACP::Host
6
+ CAPABILITIES = Set[:notify].freeze #: Set[Symbol]
7
+
8
+ # @rbs @client: ::ACP::AgentConnection::_Client
9
+ # @rbs @session_id: String?
10
+ # @rbs @buffered: Array[String]
11
+
12
+ # @rbs client: ::ACP::AgentConnection::_Client
13
+ # @rbs return: void
14
+ def initialize(client:)
15
+ @client = client
16
+ @session_id = nil
17
+ @buffered = []
18
+ end
19
+
20
+ # @rbs return: Set[Symbol]
21
+ def capabilities
22
+ CAPABILITIES
23
+ end
24
+
25
+ # @rbs question: String?
26
+ # @rbs options: Array[String]?
27
+ # @rbs secret: bool
28
+ # @rbs return: String?
29
+ def ask(question = nil, options: nil, secret: false)
30
+ nil
31
+ end
32
+
33
+ # @rbs question: String?
34
+ # @rbs return: bool
35
+ def confirm(_question = nil)
36
+ false
37
+ end
38
+
39
+ # @rbs message: String?
40
+ # @rbs level: Symbol
41
+ # @rbs return: void
42
+ def notify(message = nil, level: :info)
43
+ id = @session_id
44
+ return @buffered << message.to_s unless id
45
+
46
+ send_update(id, message.to_s)
47
+ end
48
+
49
+ # @rbs label: String?
50
+ # @rbs &block: ? () -> void
51
+ # @rbs return: void
52
+ def progress(label = nil, &block)
53
+ notify(label) if label
54
+ block&.call
55
+ end
56
+
57
+ # Session updates name their session, and acp-sdk runs session_created after
58
+ # the session/new reply so the client knows the id before any update for it
59
+ # arrives — that is why the build-time notifies wait for this, not for the
60
+ # Runtime's construction to end.
61
+ # @rbs id: String
62
+ # @rbs return: void
63
+ def session_id=(id)
64
+ @session_id = id
65
+ buffered = @buffered
66
+ @buffered = []
67
+ buffered.each { |message| send_update(id, message) }
68
+ end
69
+
70
+ private
71
+
72
+ # @rbs id: String
73
+ # @rbs message: String
74
+ # @rbs return: void
75
+ def send_update(id, message)
76
+ @client.update(
77
+ id,
78
+ ::ACP::Types::SessionUpdate::AgentMessageChunk.new(
79
+ content: ::ACP::Types::ContentBlock::Text.new(text: message)
80
+ )
81
+ )
82
+ end
83
+ end
@@ -0,0 +1,353 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'acp/sdk'
4
+ require 'time'
5
+
6
+ # The ACP tier: Runtime events reach the client only through this adapter's
7
+ # mapping, so nothing else in rig may translate an event into an ACP type.
8
+ class Riffer::Rig::ACP
9
+ AGENT_NAME = 'riffer' #: String
10
+ AGENT_TITLE = 'Riffer' #: String
11
+
12
+ SESSION_EXTENSION = 'acp' #: String
13
+
14
+ TOOL_KINDS = {
15
+ 'read' => ::ACP::Types::ToolKind::READ,
16
+ 'write' => ::ACP::Types::ToolKind::EDIT,
17
+ 'edit' => ::ACP::Types::ToolKind::EDIT,
18
+ 'bash' => ::ACP::Types::ToolKind::EXECUTE
19
+ }.freeze #: Hash[String, String]
20
+
21
+ # @rbs @input: IO
22
+ # @rbs @output: IO
23
+ # @rbs @env: Riffer::Rig::Env | Riffer::Rig::Env::Invalid
24
+ # @rbs @home: String
25
+ # @rbs @build: ^(String, Riffer::Rig::Hosts::_Host, Hash[String, Riffer::Rig::Mcp::Declaration]) -> Riffer::Rig::Runtime
26
+ # @rbs @client: ::ACP::AgentConnection::_Client?
27
+ # @rbs @sessions: Hash[String, Riffer::Rig::Runtime]
28
+ # @rbs @hosts: Hash[String, Riffer::Rig::ACP::Host]
29
+ # @rbs @lock: Thread::Mutex
30
+
31
+ # @rbs input: IO
32
+ # @rbs output: IO
33
+ # @rbs env: Riffer::Rig::Env | Riffer::Rig::Env::Invalid
34
+ # @rbs home: String
35
+ # @rbs build: (^(String, Riffer::Rig::Hosts::_Host, Hash[String, Riffer::Rig::Mcp::Declaration]) -> Riffer::Rig::Runtime)?
36
+ # @rbs return: Riffer::Rig::ACP
37
+ def self.for(input:, output:, env:, home:, build: nil)
38
+ new(input: input, output: output, env: env, home: home, build: build)
39
+ end
40
+
41
+ # Builds the Runtime a session runs on: the client's MCP servers flow
42
+ # through the rig.mcp seam as a synthetic extension, so a reload rebuild
43
+ # re-declares them and a same-name declaration from an extension replaces
44
+ # them with the usual notify.
45
+ # @rbs cwd: String
46
+ # @rbs host: Riffer::Rig::Hosts::_Host
47
+ # @rbs servers: Hash[String, Riffer::Rig::Mcp::Declaration]
48
+ # @rbs env: Riffer::Rig::Env | Riffer::Rig::Env::Invalid
49
+ # @rbs home: String
50
+ # @rbs return: Riffer::Rig::Runtime
51
+ def self.build_runtime(cwd, host, servers, env:, home:)
52
+ extension = session_extension(servers)
53
+ Riffer::Rig::Loader.runtime(
54
+ cwd: cwd, host: host, env: env, home: home, extra_extensions: extension ? [extension] : []
55
+ )
56
+ end
57
+
58
+ # @rbs servers: Hash[String, Riffer::Rig::Mcp::Declaration]
59
+ # @rbs return: Riffer::Rig::Extension?
60
+ def self.session_extension(servers)
61
+ return nil if servers.empty?
62
+
63
+ Riffer::Rig::Extension.new(SESSION_EXTENSION) do |rig|
64
+ servers.each { |name, server| rig.mcp(name, command: server.command, args: server.args, env: server.env) }
65
+ end
66
+ end
67
+
68
+ # @rbs input: IO
69
+ # @rbs output: IO
70
+ # @rbs env: Riffer::Rig::Env | Riffer::Rig::Env::Invalid
71
+ # @rbs home: String
72
+ # @rbs build: (^(String, Riffer::Rig::Hosts::_Host, Hash[String, Riffer::Rig::Mcp::Declaration]) -> Riffer::Rig::Runtime)?
73
+ # @rbs return: void
74
+ def initialize(input:, output:, env:, home:, build: nil)
75
+ @input = input
76
+ @output = output
77
+ @env = env
78
+ @home = home
79
+ @build = build || ->(cwd, host, servers) { self.class.build_runtime(cwd, host, servers, env: @env, home: @home) }
80
+ @client = nil
81
+ @sessions = {}
82
+ @hosts = {}
83
+ @lock = Mutex.new
84
+ end
85
+
86
+ # Serves the connection until the client closes stdin; then the reader
87
+ # thread ends and run returns.
88
+ # @rbs return: Integer
89
+ def run
90
+ connection = ::ACP::AgentConnection.new(
91
+ transport: ::ACP::Transport::Stdio.new(input: @input, output: @output),
92
+ capabilities: ::ACP::Types::AgentCapabilities.new(
93
+ load_session: true,
94
+ session_capabilities: ::ACP::Types::SessionCapabilities.new(list: ::ACP::Types::SessionListCapabilities.new)
95
+ ),
96
+ agent_info: ::ACP::Types::Implementation.new(name: AGENT_NAME, version: Riffer::Rig::VERSION, title: AGENT_TITLE)
97
+ ) do |client|
98
+ @client = client
99
+ self
100
+ end
101
+ connection.start.join
102
+ 0
103
+ end
104
+
105
+ # @rbs request: ::ACP::Types::NewSessionRequest
106
+ # @rbs return: (::ACP::Types::NewSessionResponse | ::ACP::RequestError)
107
+ def new_session(request)
108
+ host = Riffer::Rig::ACP::Host.new(client: client)
109
+ servers = declarations(request.mcp_servers, host)
110
+ runtime = @build.call(request.cwd, host, servers)
111
+ @lock.synchronize do
112
+ @sessions[runtime.id] = runtime
113
+ @hosts[runtime.id] = host
114
+ end
115
+ ::ACP::Types::NewSessionResponse.new(session_id: runtime.id)
116
+ rescue StandardError => e
117
+ ::ACP::RequestError.new(code: ::ACP::RequestError::INTERNAL_ERROR, message: e.message)
118
+ end
119
+
120
+ # @rbs request: ::ACP::Types::PromptRequest
121
+ # @rbs return: (::ACP::Types::PromptResponse | ::ACP::RequestError)
122
+ def prompt(request)
123
+ runtime = session(request.session_id)
124
+ return ::ACP::RequestError.resource_not_found unless runtime
125
+
126
+ # @type var stop_reason: Symbol?
127
+ stop_reason = nil
128
+ runtime.prompt(prompt_text(request.prompt)) do |event|
129
+ case event
130
+ when Riffer::StreamEvents::TextDelta
131
+ client.update(request.session_id, message_chunk(event.content))
132
+ when Riffer::StreamEvents::ToolCallDone
133
+ client.update(request.session_id, tool_call(event))
134
+ when Riffer::Rig::Events::TurnEnd
135
+ stop_reason = event.stop_reason
136
+ end
137
+ end
138
+ ::ACP::Types::PromptResponse.new(stop_reason: stop_reason_of(stop_reason))
139
+ rescue Riffer::Rig::Runtime::BusyError, Riffer::Rig::Runtime::ClosedError => e
140
+ ::ACP::RequestError.new(code: ::ACP::RequestError::INTERNAL_ERROR, message: e.message)
141
+ end
142
+
143
+ # The host takes the session id before the build, so a build-time notify
144
+ # still reaches the client as an update for this session.
145
+ # @rbs request: ::ACP::Types::LoadSessionRequest
146
+ # @rbs return: (::ACP::Types::LoadSessionResponse | ::ACP::RequestError)
147
+ def load_session(request)
148
+ host = Riffer::Rig::ACP::Host.new(client: client)
149
+ host.session_id = request.session_id
150
+ loader = Riffer::Rig::Loader.new(cwd: request.cwd, host: host, env: @env, home: @home)
151
+ runtime = loader.resume(request.session_id)
152
+ return ::ACP::RequestError.resource_not_found unless runtime
153
+
154
+ @lock.synchronize do
155
+ @sessions[request.session_id] = runtime
156
+ @hosts[request.session_id] = host
157
+ end
158
+ loader.messages(request.session_id)
159
+ .filter_map { |message| replay_of(message) }
160
+ .each { |update| client.update(request.session_id, update) }
161
+ ::ACP::Types::LoadSessionResponse.new
162
+ rescue StandardError => e
163
+ ::ACP::RequestError.new(code: ::ACP::RequestError::INTERNAL_ERROR, message: e.message)
164
+ end
165
+
166
+ # @rbs request: ::ACP::Types::ListSessionsRequest
167
+ # @rbs return: (::ACP::Types::ListSessionsResponse | ::ACP::RequestError)
168
+ def list_sessions(request)
169
+ loader = Riffer::Rig::Loader.new(
170
+ cwd: request.cwd || Dir.pwd,
171
+ host: Riffer::Rig::Hosts::Null.new,
172
+ env: @env,
173
+ home: @home
174
+ )
175
+ headers = request.cwd ? loader.list : loader.list(all: true)
176
+ sessions = headers.map do |header|
177
+ ::ACP::Types::SessionInfo.new(
178
+ session_id: header.id,
179
+ cwd: header.cwd,
180
+ title: header.title,
181
+ updated_at: header.updated&.utc&.iso8601
182
+ )
183
+ end
184
+ ::ACP::Types::ListSessionsResponse.new(sessions: sessions)
185
+ rescue StandardError => e
186
+ ::ACP::RequestError.new(code: ::ACP::RequestError::INTERNAL_ERROR, message: e.message)
187
+ end
188
+
189
+ # Runs on the transport's reader thread, so it only sets the cancel flag.
190
+ # @rbs notification: ::ACP::Types::CancelNotification
191
+ # @rbs return: void
192
+ def cancel(notification)
193
+ session(notification.session_id)&.cancel
194
+ end
195
+
196
+ # Runs after the session/new reply, so the client knows the id before any
197
+ # update for it arrives: the build-time notifies flush here, then the
198
+ # commands go out.
199
+ # @rbs response: ::ACP::Types::NewSessionResponse
200
+ # @rbs return: void
201
+ def session_created(response)
202
+ id = response.session_id
203
+ # @type var found: [Riffer::Rig::ACP::Host?, Riffer::Rig::Runtime?]
204
+ found = @lock.synchronize { [@hosts[id], @sessions[id]] }
205
+ host, runtime = found
206
+ return unless host && runtime
207
+
208
+ host.session_id = id
209
+ client.available_commands(id, available_commands(runtime))
210
+ end
211
+
212
+ private
213
+
214
+ # @rbs return: ::ACP::AgentConnection::_Client
215
+ def client
216
+ @client or raise 'ACP agent used before ::ACP::AgentConnection.start built it'
217
+ end
218
+
219
+ # @rbs id: String
220
+ # @rbs return: Riffer::Rig::Runtime?
221
+ def session(id)
222
+ @lock.synchronize { @sessions[id] }
223
+ end
224
+
225
+ # @rbs servers: Array[::ACP::Types::McpServer::t]
226
+ # @rbs host: Riffer::Rig::ACP::Host
227
+ # @rbs return: Hash[String, Riffer::Rig::Mcp::Declaration]
228
+ def declarations(servers, host)
229
+ servers.filter_map { |server| declaration_of(server, host) }.to_h
230
+ end
231
+
232
+ # @rbs server: ::ACP::Types::McpServer::t
233
+ # @rbs host: Riffer::Rig::ACP::Host
234
+ # @rbs return: [String, Riffer::Rig::Mcp::Declaration]?
235
+ def declaration_of(server, host)
236
+ unless server.is_a?(::ACP::Types::McpServerStdio)
237
+ host.notify("Skipped MCP server #{server_name(server)}: only stdio servers are supported", level: :warning)
238
+ return nil
239
+ end
240
+
241
+ [
242
+ server.name,
243
+ Riffer::Rig::Mcp::Declaration.new(command: server.command, args: server.args, env: stdio_env(server))
244
+ ]
245
+ end
246
+
247
+ # @rbs server: ::ACP::Types::McpServer::t
248
+ # @rbs return: String
249
+ def server_name(server)
250
+ case server
251
+ when ::ACP::Types::McpServer::Http, ::ACP::Types::McpServer::Sse then server.name
252
+ when Hash then server['name'].to_s
253
+ else 'unnamed'
254
+ end
255
+ end
256
+
257
+ # @rbs server: ::ACP::Types::McpServerStdio
258
+ # @rbs return: Hash[String, String]
259
+ def stdio_env(server)
260
+ server.env.to_h { |variable| [variable.name, variable.value] }
261
+ end
262
+
263
+ # Text and resource links are ACP's must-support prompt blocks; the client
264
+ # cannot send the others unless the agent advertised them.
265
+ # @rbs blocks: Array[::ACP::Types::ContentBlock::t]
266
+ # @rbs return: String
267
+ def prompt_text(blocks)
268
+ blocks.filter_map { |block| prompt_text_of(block) }.join("\n\n")
269
+ end
270
+
271
+ # @rbs block: ::ACP::Types::ContentBlock::t
272
+ # @rbs return: String?
273
+ def prompt_text_of(block)
274
+ case block
275
+ when ::ACP::Types::ContentBlock::Text then block.text
276
+ when ::ACP::Types::ContentBlock::ResourceLink then "#{block.title || block.name} #{block.uri}"
277
+ end
278
+ end
279
+
280
+ # @rbs content: String
281
+ # @rbs return: ::ACP::Types::SessionUpdate::AgentMessageChunk
282
+ def message_chunk(content)
283
+ ::ACP::Types::SessionUpdate::AgentMessageChunk.new(content: text_block(content))
284
+ end
285
+
286
+ # @rbs text: String
287
+ # @rbs return: ::ACP::Types::ContentBlock::Text
288
+ def text_block(text)
289
+ ::ACP::Types::ContentBlock::Text.new(text: text)
290
+ end
291
+
292
+ # @rbs message: Hash[Symbol, untyped]
293
+ # @rbs return: ::ACP::Types::SessionUpdate::t?
294
+ def replay_of(message)
295
+ case message[:role]
296
+ when 'user' then ::ACP::Types::SessionUpdate::UserMessageChunk.new(content: text_block(message[:content].to_s))
297
+ when 'assistant' then message_chunk(message[:content].to_s)
298
+ when 'tool' then replayed_tool_call(message)
299
+ end
300
+ end
301
+
302
+ # The stored result is all a replayed call has: the arguments live in the
303
+ # assistant message's tool_calls, so the title is the name alone.
304
+ # @rbs message: Hash[Symbol, untyped]
305
+ # @rbs return: ::ACP::Types::SessionUpdate::ToolCall
306
+ def replayed_tool_call(message)
307
+ name = message[:name].to_s
308
+ ::ACP::Types::SessionUpdate::ToolCall.new(
309
+ tool_call_id: message[:tool_call_id].to_s,
310
+ title: name,
311
+ name: name,
312
+ kind: TOOL_KINDS.fetch(name, ::ACP::Types::ToolKind::OTHER),
313
+ status: ::ACP::Types::ToolCallStatus::COMPLETED,
314
+ content: [::ACP::Types::ToolCallContent::Content.new(content: text_block(message[:content].to_s))]
315
+ )
316
+ end
317
+
318
+ # riffer's stream has no completion boundary for a tool call, so a call goes
319
+ # out once, in progress, when its arguments are complete.
320
+ # @rbs event: Riffer::StreamEvents::ToolCallDone
321
+ # @rbs return: ::ACP::Types::SessionUpdate::ToolCall
322
+ def tool_call(event)
323
+ ::ACP::Types::SessionUpdate::ToolCall.new(
324
+ tool_call_id: event.call_id,
325
+ title: "#{event.name}(#{event.arguments})",
326
+ name: event.name,
327
+ kind: TOOL_KINDS.fetch(event.name, ::ACP::Types::ToolKind::OTHER),
328
+ status: ::ACP::Types::ToolCallStatus::IN_PROGRESS
329
+ )
330
+ end
331
+
332
+ # riffer's failure reasons (:error, :other, :guardrail_blocked) have no ACP
333
+ # counterpart; refusal is the closest signal that the turn did not complete.
334
+ # @rbs stop_reason: Symbol?
335
+ # @rbs return: String
336
+ def stop_reason_of(stop_reason)
337
+ case stop_reason
338
+ when :completed then ::ACP::Types::StopReason::END_TURN
339
+ when Riffer::Rig::Runtime::INTERRUPT_CANCELLED then ::ACP::Types::StopReason::CANCELLED
340
+ when :max_steps then ::ACP::Types::StopReason::MAX_TURN_REQUESTS
341
+ when :context_window, :length, :content_filter, :malformed_output then ::ACP::Types::StopReason::MAX_TOKENS
342
+ else ::ACP::Types::StopReason::REFUSAL
343
+ end
344
+ end
345
+
346
+ # @rbs runtime: Riffer::Rig::Runtime
347
+ # @rbs return: Array[::ACP::Types::AvailableCommand]
348
+ def available_commands(runtime)
349
+ runtime.commands.map do |command|
350
+ ::ACP::Types::AvailableCommand.new(name: command.name, description: command.description)
351
+ end
352
+ end
353
+ end
@@ -3,7 +3,12 @@
3
3
  module Riffer::Rig::Bundled
4
4
  Mcp = Riffer::Rig::Extension.new('mcp') do |rig|
5
5
  rig.settings[:servers].to_h.each do |name, server|
6
- rig.mcp(name.to_s, url: server.fetch(:url), headers: server[:headers].to_h.transform_keys(&:to_s))
6
+ rig.mcp(
7
+ name.to_s,
8
+ url: server.fetch(:url),
9
+ headers: server[:headers].to_h.transform_keys(&:to_s),
10
+ auth: Hash(server[:auth])
11
+ )
7
12
  end
8
13
  end #: Riffer::Rig::Extension
9
14
  end