@beunec/rivine 0.1.5 → 0.2.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.
- package/README.md +78 -38
- package/action.yml +25 -8
- package/dist/api/workspace.js +3 -0
- package/dist/index.js +34 -11
- package/dist/protocol/permissions.js +44 -4
- package/dist/protocol/runner.js +23 -0
- package/dist/runHeadless.js +1 -0
- package/dist/ui/App.js +35 -5
- package/dist/ui/components.js +31 -6
- package/dist/ui/theme.js +12 -0
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
# Rivine Code, in your terminal
|
|
2
2
|
|
|
3
|
+
Requires Node 20 or newer.
|
|
4
|
+
|
|
3
5
|
```
|
|
4
6
|
npm install -g @beunec/rivine
|
|
5
7
|
cd your-project
|
|
8
|
+
rivine login
|
|
6
9
|
rivine
|
|
7
10
|
```
|
|
8
11
|
|
|
@@ -42,8 +45,7 @@ stops it; the agent sees the non-zero exit and decides what to do about it.
|
|
|
42
45
|
|
|
43
46
|
The terminal comes from `node-pty`, an optional dependency. If it cannot be
|
|
44
47
|
installed or a PTY cannot be allocated, commands still run and still stream —
|
|
45
|
-
just without a terminal. Nothing fails
|
|
46
|
-
you want to know which one you have. `RIVINE_NO_PTY=1` forces the pipe path.
|
|
48
|
+
just without a terminal. Nothing fails. `RIVINE_NO_PTY=1` forces the pipe path.
|
|
47
49
|
|
|
48
50
|
What you see and what the model is sent are deliberately different. Escape codes
|
|
49
51
|
are stripped and redrawn lines are collapsed to their final state before the
|
|
@@ -51,33 +53,64 @@ output leaves your machine, so a progress bar that repainted four hundred times
|
|
|
51
53
|
costs one line rather than four hundred. This is why a real terminal does not
|
|
52
54
|
cost more per turn than not having one.
|
|
53
55
|
|
|
54
|
-
##
|
|
56
|
+
## Mode and Rate
|
|
57
|
+
|
|
58
|
+
Two choices, both required, and neither is inferred from what you type. There is
|
|
59
|
+
no router — no keyword matcher, no classifier, no extra model turn that reads
|
|
60
|
+
your request and picks a lane. A wrong guess sends a database migration to the
|
|
61
|
+
frontend agent and you never see the decision that was made for you.
|
|
62
|
+
|
|
63
|
+
### Mode — which agent runs
|
|
64
|
+
|
|
65
|
+
Each Mode is one standalone agent that owns the task end to end: it searches the
|
|
66
|
+
codebase, reads what it needs, writes across as many files as the change
|
|
67
|
+
requires, runs the build and tests, and fixes what the terminal reports.
|
|
68
|
+
|
|
69
|
+
| Mode | For | How to choose |
|
|
70
|
+
|---|---|---|
|
|
71
|
+
| `frontend` | UI and client tier | `1`, `-m frontend` |
|
|
72
|
+
| `backend` | Server, API, database | `2`, `-m backend` |
|
|
73
|
+
| `fullstack` | A change spanning every tier | `3`, `-m fullstack` |
|
|
74
|
+
| `simple` | Plan, ask, document, refresh, ship | `4`, `-m simple`, `--plan` |
|
|
75
|
+
| `security` | Find, verify, and patch vulnerabilities | `5`, `-m security` |
|
|
76
|
+
|
|
77
|
+
`simple` is the only Mode with live web access — its work is the work that
|
|
78
|
+
depends on the world outside the repository. The build Modes are measured on
|
|
79
|
+
whether the software runs.
|
|
80
|
+
|
|
81
|
+
### Rate — how edits apply
|
|
55
82
|
|
|
56
83
|
The engine runs on our servers. The decision about what happens to your machine
|
|
57
84
|
is made **here**, on your machine, before anything runs — never on our servers.
|
|
58
85
|
|
|
59
|
-
|
|
|
86
|
+
| Rate | Applies without asking | How to choose |
|
|
60
87
|
|---|---|---|
|
|
61
|
-
| `
|
|
62
|
-
| `
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
- **Anything outside the project.**
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
- **
|
|
80
|
-
|
|
88
|
+
| `manual` | Reads and read-only commands. Asks before every write, edit, or delete | `1`, `--rate manual` (default) |
|
|
89
|
+
| `bypass` | Writes and ordinary commands inside this project | `2`, `--rate bypass`, `--bypass` |
|
|
90
|
+
|
|
91
|
+
Three things ask in **both** Rates, because they are what you would want to see
|
|
92
|
+
even having said "just do it": credentials, anything outside the project
|
|
93
|
+
directory, and commands that are hard to undo (`rm -rf`, `git push --force`, a
|
|
94
|
+
deploy).
|
|
95
|
+
|
|
96
|
+
The first interactive session asks for Mode, then Rate. `-m …` and `--rate …`
|
|
97
|
+
skip the questions because you already answered them. Shift+Tab toggles the
|
|
98
|
+
Rate.
|
|
99
|
+
|
|
100
|
+
What stays gated:
|
|
101
|
+
|
|
102
|
+
- **Anything outside the project.** Refused in `bypass` (set
|
|
103
|
+
`RIVINE_ALLOW_OUTSIDE_PROJECT=1` if you mean it). Other modes ask. The jail
|
|
104
|
+
resolves symlinks first, so a link pointing out of the directory is caught
|
|
105
|
+
rather than followed.
|
|
106
|
+
- **Credentials** — every mode except `bypass`. `.env`, `*.pem`, `~/.ssh`,
|
|
107
|
+
`~/.aws`, service-account JSON. These ask on *read* too — reading one puts
|
|
108
|
+
a live secret into a model prompt.
|
|
109
|
+
- **Commands that are hard to undo** — every mode except `bypass`. `rm -rf`,
|
|
110
|
+
`git push`, `--force`, `git reset --hard`, `sudo`, `npm publish`, any
|
|
111
|
+
deploy, `terraform apply`.
|
|
112
|
+
- **Batch writes** — every mode except `bypass`. Judged by their most
|
|
113
|
+
dangerous entry, so a safe first file cannot clear a whole batch.
|
|
81
114
|
|
|
82
115
|
One exemption: `.rivine/` — the agent's own notebook for this project, seeded at
|
|
83
116
|
session start and appended to at the end of each turn. It writes without asking
|
|
@@ -90,7 +123,8 @@ session start and appended to at the end of each turn. It writes without asking
|
|
|
90
123
|
rivine start a session in the current directory
|
|
91
124
|
rivine "add tests for auth.ts" one prompt, then exit
|
|
92
125
|
rivine -p "..." the same, explicitly
|
|
93
|
-
rivine
|
|
126
|
+
rivine -m frontend start in Frontend Only
|
|
127
|
+
rivine --plan shorthand for -m simple (Plan/Ask Only)
|
|
94
128
|
rivine -C ../other-project run against a different directory
|
|
95
129
|
|
|
96
130
|
rivine login sign in (opens your browser)
|
|
@@ -105,8 +139,9 @@ rivine --output-format json one JSON object on stdout, for CI
|
|
|
105
139
|
```
|
|
106
140
|
|
|
107
141
|
In a session: type `/` and press Tab. `/help` `/mode` `/plan` `/accept`
|
|
108
|
-
`/status` `/diff` `/pr` `/mcp` `/credits` `/clear` `/exit`.
|
|
109
|
-
permission
|
|
142
|
+
`/status` `/diff` `/pr` `/mcp` `/credits` `/clear` `/exit`. `/mode` opens the
|
|
143
|
+
permission picker. Shift+Tab cycles plan / default / accept edits. Ctrl+C
|
|
144
|
+
quits cleanly.
|
|
110
145
|
|
|
111
146
|
`/diff` shows what changed in the working tree. `/pr` prepares a pull request
|
|
112
147
|
through your own `gh` CLI. It **never pushes without being asked**: on a
|
|
@@ -139,8 +174,8 @@ names the flag that would have allowed it. Granting write access because nobody
|
|
|
139
174
|
was watching is exactly backwards.
|
|
140
175
|
|
|
141
176
|
```
|
|
142
|
-
rivine -p "run the tests and fix what fails"
|
|
143
|
-
rivine -p "..."
|
|
177
|
+
rivine -m backend -p "run the tests and fix what fails"
|
|
178
|
+
rivine -m fullstack --rate bypass -p "..." # CI, on a machine you accept changes to
|
|
144
179
|
```
|
|
145
180
|
|
|
146
181
|
## Sign-in
|
|
@@ -262,11 +297,12 @@ pipe into `jq`:
|
|
|
262
297
|
{ "ok": true, "answer": "…", "error": null, "exit_code": 0, "files_written": ["src/a.ts"] }
|
|
263
298
|
```
|
|
264
299
|
|
|
265
|
-
Exit codes are `0` success, `1` error, `2` out of credits
|
|
266
|
-
|
|
267
|
-
terminal to give it. `3`
|
|
268
|
-
|
|
269
|
-
|
|
300
|
+
Exit codes are `0` success, `1` error, `2` out of credits (also if you run
|
|
301
|
+
`rivine` with no prompt and no terminal), and `3` nothing could be done —
|
|
302
|
+
every operation needed approval and there was no terminal to give it. `3`
|
|
303
|
+
exists because that run still costs a turn: without it the step would go
|
|
304
|
+
green having changed nothing. The result object carries `refused` alongside
|
|
305
|
+
`files_written` so a workflow can tell the two apart.
|
|
270
306
|
|
|
271
307
|
There is a composite action in `action.yml`:
|
|
272
308
|
|
|
@@ -315,10 +351,11 @@ is one, it will be linked here rather than described.
|
|
|
315
351
|
|
|
316
352
|
## Credits
|
|
317
353
|
|
|
318
|
-
The same wallet as the web app, checked by the same gate. Rivine Code needs
|
|
319
|
-
|
|
320
|
-
the top-up page rather than starting work it cannot finish.
|
|
321
|
-
the status bar show your balance.
|
|
354
|
+
The same wallet as the web app, checked by the same gate. Rivine Code needs a
|
|
355
|
+
minimum balance to start a turn (usually **$10.00**); below that the CLI says
|
|
356
|
+
so and links to the top-up page rather than starting work it cannot finish.
|
|
357
|
+
`rivine status` and the status bar show your balance. The bar refreshes after
|
|
358
|
+
each turn.
|
|
322
359
|
|
|
323
360
|
## Configuration
|
|
324
361
|
|
|
@@ -330,6 +367,9 @@ deployments:
|
|
|
330
367
|
| `RIVINE_API_URL` | `https://workspace.beunec.com` |
|
|
331
368
|
| `RIVINE_SITE_URL` | `https://aselius.beunec.com` |
|
|
332
369
|
| `RIVINE_TOKEN` | unset — a pre-issued token for CI |
|
|
370
|
+
| `RIVINE_NO_PTY` | unset — set to `1` to run commands without a PTY |
|
|
371
|
+
| `RIVINE_ALLOW_OUTSIDE_PROJECT` | unset — set to `1` only if `bypass` should be allowed to leave the project directory |
|
|
372
|
+
| `RIVINE_ASCII` | unset — set to `1` for ASCII glyphs (narrow panes, no Unicode) |
|
|
333
373
|
|
|
334
374
|
Auth0 parameters are fetched from `GET /api/cli/config` at sign-in, so this
|
|
335
375
|
package carries no tenant configuration and a client-id change never requires
|
package/action.yml
CHANGED
|
@@ -38,8 +38,16 @@ inputs:
|
|
|
38
38
|
required: true
|
|
39
39
|
mode:
|
|
40
40
|
description: >-
|
|
41
|
-
|
|
42
|
-
|
|
41
|
+
Which standalone agent runs: frontend, backend, fullstack, simple, or
|
|
42
|
+
security. Required — there is no router, and guessing would send a
|
|
43
|
+
database migration to the frontend agent without anyone seeing the
|
|
44
|
+
decision.
|
|
45
|
+
required: true
|
|
46
|
+
rate:
|
|
47
|
+
description: >-
|
|
48
|
+
How edits apply: manual or bypass. `bypass` is the default here because
|
|
49
|
+
there is nobody to approve anything; `manual` refuses every operation
|
|
50
|
+
that would prompt, which in CI means it stops.
|
|
43
51
|
required: false
|
|
44
52
|
default: bypass
|
|
45
53
|
working-directory:
|
|
@@ -98,6 +106,7 @@ runs:
|
|
|
98
106
|
# concerned. They are read as variables and never spliced into it.
|
|
99
107
|
RIVINE_PROMPT: ${{ inputs.prompt }}
|
|
100
108
|
RIVINE_MODE: ${{ inputs.mode }}
|
|
109
|
+
RIVINE_RATE: ${{ inputs.rate }}
|
|
101
110
|
# Read by the CLI in place of an interactive sign-in. Store it as a
|
|
102
111
|
# repository secret; it is an access token for a real account with a
|
|
103
112
|
# real credit balance.
|
|
@@ -115,13 +124,21 @@ runs:
|
|
|
115
124
|
exit 1
|
|
116
125
|
fi
|
|
117
126
|
|
|
118
|
-
# An allowlist, not a quoted interpolation:
|
|
119
|
-
# words, and anything else is a mistake worth stopping on
|
|
120
|
-
# passing to the CLI to reject later.
|
|
127
|
+
# An allowlist, not a quoted interpolation: each of these is one of a
|
|
128
|
+
# closed set of words, and anything else is a mistake worth stopping on
|
|
129
|
+
# rather than passing to the CLI to reject later.
|
|
121
130
|
case "$RIVINE_MODE" in
|
|
122
|
-
|
|
131
|
+
frontend|backend|fullstack|simple|security) ;;
|
|
132
|
+
*)
|
|
133
|
+
echo "::error::Unknown mode '$RIVINE_MODE'. Use frontend, backend, fullstack, simple or security."
|
|
134
|
+
exit 1
|
|
135
|
+
;;
|
|
136
|
+
esac
|
|
137
|
+
|
|
138
|
+
case "$RIVINE_RATE" in
|
|
139
|
+
manual|bypass) ;;
|
|
123
140
|
*)
|
|
124
|
-
echo "::error::Unknown
|
|
141
|
+
echo "::error::Unknown rate '$RIVINE_RATE'. Use manual or bypass."
|
|
125
142
|
exit 1
|
|
126
143
|
;;
|
|
127
144
|
esac
|
|
@@ -129,7 +146,7 @@ runs:
|
|
|
129
146
|
# stdout is exactly one JSON object in this mode; the readable
|
|
130
147
|
# transcript and any command output go to stderr, which is what shows
|
|
131
148
|
# up in the workflow log.
|
|
132
|
-
rivine --output-format json -m "$RIVINE_MODE" -p "$RIVINE_PROMPT" > "$RUNNER_TEMP/rivine.json"
|
|
149
|
+
rivine --output-format json -m "$RIVINE_MODE" --rate "$RIVINE_RATE" -p "$RIVINE_PROMPT" > "$RUNNER_TEMP/rivine.json"
|
|
133
150
|
status=$?
|
|
134
151
|
|
|
135
152
|
if [ ! -s "$RUNNER_TEMP/rivine.json" ]; then
|
package/dist/api/workspace.js
CHANGED
|
@@ -76,6 +76,9 @@ export async function openTurn(req) {
|
|
|
76
76
|
client: 'cli',
|
|
77
77
|
cli_session_id: req.sessionId,
|
|
78
78
|
permission_mode: req.permissionMode,
|
|
79
|
+
code_mode: req.codeMode ?? null,
|
|
80
|
+
code_rate: req.codeRate ?? null,
|
|
81
|
+
specialty: req.codeMode ?? null,
|
|
79
82
|
cwd_snapshot: req.cwdSnapshot ?? null,
|
|
80
83
|
mcp_tools: req.mcpTools ?? null,
|
|
81
84
|
cli_session_key: req.sessionKey ?? null,
|
package/dist/index.js
CHANGED
|
@@ -25,7 +25,7 @@ import { describeServer, loadServers, removeServer, saveServer } from './mcp/con
|
|
|
25
25
|
import { McpManager } from './mcp/manager.js';
|
|
26
26
|
import { loginToServer } from './mcp/oauth.js';
|
|
27
27
|
import { clearSession, mcpAccount } from './auth/keychain.js';
|
|
28
|
-
import {
|
|
28
|
+
import { CODE_MODES, RATES, parseCodeMode, rateToPermissionMode, resolveRate, } from './protocol/permissions.js';
|
|
29
29
|
const program = new Command();
|
|
30
30
|
function readPackageVersion() {
|
|
31
31
|
try {
|
|
@@ -45,11 +45,16 @@ function resolveMode(options) {
|
|
|
45
45
|
if (options.bypass)
|
|
46
46
|
return { mode: 'bypass', explicit: true };
|
|
47
47
|
if (options.plan)
|
|
48
|
-
return { mode: '
|
|
49
|
-
const
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
48
|
+
return { mode: 'default', explicit: true };
|
|
49
|
+
const rate = resolveRate(options.rate);
|
|
50
|
+
return { mode: rateToPermissionMode(rate), explicit: Boolean(options.rate) };
|
|
51
|
+
}
|
|
52
|
+
function resolveCodeMode(options) {
|
|
53
|
+
if (options.mode)
|
|
54
|
+
return parseCodeMode(options.mode);
|
|
55
|
+
if (options.plan)
|
|
56
|
+
return 'simple';
|
|
57
|
+
return null;
|
|
53
58
|
}
|
|
54
59
|
program
|
|
55
60
|
.command('login', { isDefault: false })
|
|
@@ -257,8 +262,9 @@ function collect(value, previous) {
|
|
|
257
262
|
program
|
|
258
263
|
.argument('[prompt...]', 'send one prompt and exit')
|
|
259
264
|
.option('-p, --print <prompt>', 'send one prompt, print the answer, and exit')
|
|
260
|
-
.option('-m, --mode <mode>', `
|
|
261
|
-
.option('--
|
|
265
|
+
.option('-m, --mode <mode>', `which agent runs: ${CODE_MODES.join(' | ')}`)
|
|
266
|
+
.option('--rate <rate>', `how edits apply: ${RATES.join(' | ')}`, 'manual')
|
|
267
|
+
.option('--plan', 'shorthand for --mode simple (Plan/Ask Only)')
|
|
262
268
|
.option('--bypass', 'never ask for approval — for CI only, on a machine you are willing to have changed')
|
|
263
269
|
.option('-C, --cwd <dir>', 'run against a different directory')
|
|
264
270
|
.option('--output-format <format>', 'text | json — json puts one object on stdout and everything else on stderr', 'text')
|
|
@@ -266,25 +272,42 @@ program
|
|
|
266
272
|
const cwd = options.cwd ? resolvePath(options.cwd) : process.cwd();
|
|
267
273
|
const oneShot = options.print ?? (prompt.length ? prompt.join(' ') : undefined);
|
|
268
274
|
const { mode, explicit: modeExplicit } = resolveMode(options);
|
|
275
|
+
const codeMode = resolveCodeMode(options);
|
|
276
|
+
if (options.mode && !codeMode) {
|
|
277
|
+
console.error(`\n Unknown mode ${JSON.stringify(options.mode)}.\n` +
|
|
278
|
+
` Choose one of: ${CODE_MODES.join(', ')}\n`);
|
|
279
|
+
process.exitCode = 2;
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
269
282
|
const format = options.outputFormat === 'json' ? 'json' : 'text';
|
|
270
283
|
if (!isInteractiveTerminal()) {
|
|
271
284
|
if (!oneShot) {
|
|
272
285
|
console.error('\n No interactive terminal detected.\n' +
|
|
273
|
-
' Pass a prompt to run once: rivine -p "add tests for auth.ts"\n');
|
|
286
|
+
' Pass a prompt to run once: rivine -m backend -p "add tests for auth.ts"\n');
|
|
274
287
|
process.exitCode = 2;
|
|
275
288
|
return;
|
|
276
289
|
}
|
|
277
|
-
|
|
290
|
+
if (!codeMode) {
|
|
291
|
+
console.error('\n --mode is required without a terminal to ask on.\n' +
|
|
292
|
+
` Choose one of: ${CODE_MODES.join(', ')}\n` +
|
|
293
|
+
' e.g. rivine -m backend --rate bypass -p "add tests for auth.ts"\n');
|
|
294
|
+
process.exitCode = 2;
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
process.exitCode = await runHeadless({ cwd, mode, codeMode, prompt: oneShot, format });
|
|
278
298
|
return;
|
|
279
299
|
}
|
|
280
300
|
if (format === 'json' && oneShot) {
|
|
281
|
-
process.exitCode = await runHeadless({
|
|
301
|
+
process.exitCode = await runHeadless({
|
|
302
|
+
cwd, mode, codeMode: codeMode ?? 'fullstack', prompt: oneShot, format,
|
|
303
|
+
});
|
|
282
304
|
return;
|
|
283
305
|
}
|
|
284
306
|
const { waitUntilExit } = render(React.createElement(App, {
|
|
285
307
|
cwd,
|
|
286
308
|
initialMode: mode,
|
|
287
309
|
askForMode: !modeExplicit && !oneShot,
|
|
310
|
+
initialCodeMode: codeMode ?? (oneShot ? 'fullstack' : undefined),
|
|
288
311
|
oneShot,
|
|
289
312
|
}), { exitOnCtrlC: false });
|
|
290
313
|
const removeTeardown = installTeardown(() => { });
|
|
@@ -8,6 +8,44 @@
|
|
|
8
8
|
* accompanying it; it is not open source and is not licensed for
|
|
9
9
|
* redistribution or derivative works.
|
|
10
10
|
*/
|
|
11
|
+
export const RATES = ['manual', 'bypass'];
|
|
12
|
+
export const CYCLE_RATES = ['manual', 'bypass'];
|
|
13
|
+
export function resolveRate(raw) {
|
|
14
|
+
const key = (raw ?? '').trim().toLowerCase().replace(/[\s_\-]+/g, '');
|
|
15
|
+
if (key === 'bypass' || key === 'auto' || key === 'yolo' || key === 'bypasspermissions') {
|
|
16
|
+
return 'bypass';
|
|
17
|
+
}
|
|
18
|
+
return 'manual';
|
|
19
|
+
}
|
|
20
|
+
export function rateToPermissionMode(rate) {
|
|
21
|
+
return rate === 'bypass' ? 'bypass' : 'default';
|
|
22
|
+
}
|
|
23
|
+
export function permissionModeToRate(mode) {
|
|
24
|
+
return mode === 'bypass' ? 'bypass' : 'manual';
|
|
25
|
+
}
|
|
26
|
+
export const CODE_MODES = [
|
|
27
|
+
'frontend', 'backend', 'fullstack', 'simple', 'security',
|
|
28
|
+
];
|
|
29
|
+
export const CODE_MODE_LABELS = {
|
|
30
|
+
frontend: 'Frontend Only',
|
|
31
|
+
backend: 'Backend Only',
|
|
32
|
+
fullstack: 'Full Stack',
|
|
33
|
+
simple: 'Plan/Ask Only',
|
|
34
|
+
security: 'Security Scan/Fix',
|
|
35
|
+
};
|
|
36
|
+
export function parseCodeMode(raw) {
|
|
37
|
+
const key = (raw ?? '').trim().toLowerCase().replace(/[\s_\-\/]+/g, '');
|
|
38
|
+
const table = {
|
|
39
|
+
frontend: 'frontend', frontendonly: 'frontend', fe: 'frontend',
|
|
40
|
+
backend: 'backend', backendonly: 'backend', database: 'backend', be: 'backend',
|
|
41
|
+
backenddatabase: 'backend', backendanddatabase: 'backend', db: 'backend',
|
|
42
|
+
fullstack: 'fullstack', hybrid: 'fullstack', softwareengineer: 'fullstack',
|
|
43
|
+
frontendbackenddatabase: 'fullstack',
|
|
44
|
+
simple: 'simple', plan: 'simple', ask: 'simple', planaskonly: 'simple', docs: 'simple',
|
|
45
|
+
security: 'security', securityscanfix: 'security', audit: 'security', sec: 'security',
|
|
46
|
+
};
|
|
47
|
+
return table[key] ?? null;
|
|
48
|
+
}
|
|
11
49
|
export const PERMISSION_MODES = ['plan', 'default', 'acceptEdits', 'bypass'];
|
|
12
50
|
export const CYCLE_MODES = ['plan', 'default', 'acceptEdits'];
|
|
13
51
|
export function isBatchable(category) {
|
|
@@ -209,10 +247,12 @@ export function decide(input) {
|
|
|
209
247
|
return { verdict: 'ask', category: 'command', reason: 'unrecognised operation' };
|
|
210
248
|
}
|
|
211
249
|
export function nextMode(current) {
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
250
|
+
return permissionModeToRate(current) === 'bypass'
|
|
251
|
+
? rateToPermissionMode('manual')
|
|
252
|
+
: rateToPermissionMode('bypass');
|
|
253
|
+
}
|
|
254
|
+
export function nextRate(current) {
|
|
255
|
+
return current === 'bypass' ? 'manual' : 'bypass';
|
|
216
256
|
}
|
|
217
257
|
export function modeName(mode) {
|
|
218
258
|
switch (mode) {
|
package/dist/protocol/runner.js
CHANGED
|
@@ -13,6 +13,7 @@ import { apiUrl } from '../config.js';
|
|
|
13
13
|
import { accessToken } from '../auth/session.js';
|
|
14
14
|
import { classify, execute } from './fsExecutor.js';
|
|
15
15
|
import { isSealedEnvelope, SessionKey } from './sessionKey.js';
|
|
16
|
+
import { permissionModeToRate } from './permissions.js';
|
|
16
17
|
import { selectRelevantTools } from '../mcp/relevance.js';
|
|
17
18
|
import { readTurnStream } from './stream.js';
|
|
18
19
|
const PENDING_POLL_MS = 1200;
|
|
@@ -36,6 +37,9 @@ export class TurnRunner {
|
|
|
36
37
|
setMode(mode) {
|
|
37
38
|
this.ctx.mode = mode;
|
|
38
39
|
}
|
|
40
|
+
setCodeMode(mode) {
|
|
41
|
+
this.ctx.codeMode = mode;
|
|
42
|
+
}
|
|
39
43
|
writeToShell(data) {
|
|
40
44
|
if (!this.shell)
|
|
41
45
|
return false;
|
|
@@ -82,6 +86,8 @@ export class TurnRunner {
|
|
|
82
86
|
sessionId: this.ctx.sessionId,
|
|
83
87
|
message,
|
|
84
88
|
permissionMode: this.ctx.mode,
|
|
89
|
+
codeMode: this.ctx.codeMode ?? null,
|
|
90
|
+
codeRate: permissionModeToRate(this.ctx.mode),
|
|
85
91
|
cwdSnapshot: snapshot,
|
|
86
92
|
mcpTools: selection.tools.map(({ name, description, input_schema }) => ({
|
|
87
93
|
name,
|
|
@@ -115,6 +121,23 @@ export class TurnRunner {
|
|
|
115
121
|
this.cb.onTool(String(e.name ?? 'tool'), String(detail));
|
|
116
122
|
break;
|
|
117
123
|
}
|
|
124
|
+
case 'file_diff': {
|
|
125
|
+
const e = event;
|
|
126
|
+
const path = String(e.path ?? '').trim();
|
|
127
|
+
if (!path)
|
|
128
|
+
break;
|
|
129
|
+
this.cb.onFileDiff?.({
|
|
130
|
+
path: path.replace(/^\/workspace\/?/, ''),
|
|
131
|
+
stat: String(e.stat ?? ''),
|
|
132
|
+
patch: String(e.patch ?? ''),
|
|
133
|
+
added: Number(e.added ?? 0),
|
|
134
|
+
removed: Number(e.removed ?? 0),
|
|
135
|
+
isNew: Boolean(e.is_new),
|
|
136
|
+
isDelete: Boolean(e.is_delete),
|
|
137
|
+
binary: Boolean(e.binary),
|
|
138
|
+
});
|
|
139
|
+
break;
|
|
140
|
+
}
|
|
118
141
|
case 'code_file': {
|
|
119
142
|
const e = event;
|
|
120
143
|
const path = String(e.path ?? '').trim();
|
package/dist/runHeadless.js
CHANGED
package/dist/ui/App.js
CHANGED
|
@@ -16,11 +16,11 @@ import { fetchCredits, InsufficientCredits } from '../api/workspace.js';
|
|
|
16
16
|
import { openBrowser } from '../auth/pkce.js';
|
|
17
17
|
import { siteUrl } from '../config.js';
|
|
18
18
|
import { TurnRunner } from '../protocol/runner.js';
|
|
19
|
-
import { describeMode, isBatchable, mcpServerOf, nextMode, } from '../protocol/permissions.js';
|
|
19
|
+
import { CODE_MODE_LABELS, describeMode, isBatchable, mcpServerOf, nextMode, } from '../protocol/permissions.js';
|
|
20
20
|
import { creditShortfall, startSession } from '../session.js';
|
|
21
21
|
import { sanitizeForModel } from '../protocol/shell.js';
|
|
22
22
|
import { diff as gitDiff, gitBranchLine, prCreate } from '../protocol/git.js';
|
|
23
|
-
import { Activity, ApprovalPrompt, Banner, ModePicker, WORDMARK_MIN_COLUMNS, ChoicePrompt, Completions, HelpPanel, StatusBar, TranscriptLine, ShellPane, SHELL_PANE_LINES, } from './components.js';
|
|
23
|
+
import { Activity, ApprovalPrompt, Banner, CodeModePicker, ModePicker, WORDMARK_MIN_COLUMNS, ChoicePrompt, Completions, HelpPanel, StatusBar, TranscriptLine, ShellPane, SHELL_PANE_LINES, } from './components.js';
|
|
24
24
|
import { TokenBuffer, terminalWidth } from './render.js';
|
|
25
25
|
import { color } from './theme.js';
|
|
26
26
|
import { completeInput, completionsFor } from './slashCommands.js';
|
|
@@ -36,9 +36,11 @@ function grantKey(category, request) {
|
|
|
36
36
|
return category;
|
|
37
37
|
return `mcp:${mcpServerOf(String(request.args?.tool ?? '')) ?? 'unknown'}`;
|
|
38
38
|
}
|
|
39
|
-
export function App({ cwd, initialMode, askForMode, oneShot }) {
|
|
39
|
+
export function App({ cwd, initialMode, askForMode, initialCodeMode, oneShot }) {
|
|
40
40
|
const { exit } = useApp();
|
|
41
|
-
const [
|
|
41
|
+
const [codeMode, setCodeMode] = useState(initialCodeMode);
|
|
42
|
+
const codeModeChosenRef = useRef(Boolean(initialCodeMode));
|
|
43
|
+
const [phase, setPhase] = useState(initialCodeMode ? (askForMode ? 'mode' : 'starting') : 'codeMode');
|
|
42
44
|
const sessionReadyRef = useRef(false);
|
|
43
45
|
const modeChosenRef = useRef(!askForMode);
|
|
44
46
|
const [lines, setLines] = useState([]);
|
|
@@ -112,6 +114,18 @@ export function App({ cwd, initialMode, askForMode, oneShot }) {
|
|
|
112
114
|
catch {
|
|
113
115
|
}
|
|
114
116
|
}, []);
|
|
117
|
+
const pickCodeMode = useCallback((chosen) => {
|
|
118
|
+
setCodeMode(chosen);
|
|
119
|
+
codeModeChosenRef.current = true;
|
|
120
|
+
runnerRef.current?.setCodeMode(chosen);
|
|
121
|
+
push('system', `Mode: ${CODE_MODE_LABELS[chosen]}`);
|
|
122
|
+
setPhase(modeChosenRef.current
|
|
123
|
+
? (sessionReadyRef.current ? 'ready' : 'starting')
|
|
124
|
+
: 'mode');
|
|
125
|
+
}, [push]);
|
|
126
|
+
const cancelCodeModePick = useCallback(() => {
|
|
127
|
+
setPhase(sessionReadyRef.current ? 'ready' : 'starting');
|
|
128
|
+
}, []);
|
|
115
129
|
const pickMode = useCallback((chosen) => {
|
|
116
130
|
setMode(chosen);
|
|
117
131
|
modeChosenRef.current = true;
|
|
@@ -324,6 +338,22 @@ export function App({ cwd, initialMode, askForMode, oneShot }) {
|
|
|
324
338
|
push('file', path.replace(/^\/workspace\//, ''));
|
|
325
339
|
}
|
|
326
340
|
},
|
|
341
|
+
onFileDiff: (diff) => {
|
|
342
|
+
settleLive();
|
|
343
|
+
if (diff.binary) {
|
|
344
|
+
push('file', `${diff.path} (binary)`);
|
|
345
|
+
return;
|
|
346
|
+
}
|
|
347
|
+
push('file', diff.stat || diff.path);
|
|
348
|
+
for (const line of diff.patch.split('\n')) {
|
|
349
|
+
if (!line || line.startsWith('---') || line.startsWith('+++'))
|
|
350
|
+
continue;
|
|
351
|
+
push(line.startsWith('+') ? 'diff-add'
|
|
352
|
+
: line.startsWith('-') ? 'diff-del'
|
|
353
|
+
: line.startsWith('@@') ? 'diff-hunk'
|
|
354
|
+
: 'diff-ctx', line);
|
|
355
|
+
}
|
|
356
|
+
},
|
|
327
357
|
onNotice: (text) => push('notice', text),
|
|
328
358
|
onOutput: (text) => {
|
|
329
359
|
shellBuf.current = trimToPane(shellBuf.current + sanitizeForModel(text));
|
|
@@ -487,7 +517,7 @@ export function App({ cwd, initialMode, askForMode, oneShot }) {
|
|
|
487
517
|
const compactBanner = terminalWidth() < WORDMARK_MIN_COLUMNS || Boolean(oneShot);
|
|
488
518
|
const matches = completionsFor(input);
|
|
489
519
|
const busy = phase !== 'ready';
|
|
490
|
-
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Static, { items: staticItems, children: (item) => item.kind === 'banner' ? (_jsx(Banner, { compact: compactBanner }, "banner")) : (_jsx(TranscriptLine, { line: item.line }, item.line.id)) }, screenGeneration), live ? (_jsx(Box, { paddingX: 1, children: _jsx(Text, { color: color('text'), children: live }) })) : null, shellView ? _jsx(ShellPane, { text: shellView, live: shellActive }) : null, phase === 'running' && stage ? _jsx(Activity, { stage: stage, tool: tool }) : null, phase === 'mode' ? (_jsx(ModePicker, { onPick: pickMode, current: modeChosenRef.current ? mode : undefined, onCancel: modeChosenRef.current ? cancelModePick : undefined })) : null, phase === 'approval' && approval ? (_jsx(ApprovalPrompt, { approval: approval, allowAlways: isBatchable(approval.category), onChoice: answerApproval })) : null, phase === 'question' && question ? (question.options?.length ? (_jsx(ChoicePrompt, { prompt: question.prompt, options: question.options, onPick: answerQuestion })) : (_jsxs(Box, { flexDirection: "column", paddingX: 1, paddingY: 1, children: [_jsx(Text, { color: color('accent'), bold: true, children: question.prompt }), _jsxs(Box, { marginTop: 1, children: [_jsx(Text, { color: color('user'), children: '› ' }), _jsx(TextInput, { placeholder: "your answer", onSubmit: answerQuestion })] })] }))) : null, phase === 'hitl' && hitl ? (_jsx(ChoicePrompt, { prompt: hitl.tool ? `${hitl.tool} — ${hitl.message}` : hitl.message, options: hitl.options?.length ? hitl.options : ['Approve', 'Decline'], onPick: (value) => answerHitl(hitl.options?.length
|
|
520
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Static, { items: staticItems, children: (item) => item.kind === 'banner' ? (_jsx(Banner, { compact: compactBanner }, "banner")) : (_jsx(TranscriptLine, { line: item.line }, item.line.id)) }, screenGeneration), live ? (_jsx(Box, { paddingX: 1, children: _jsx(Text, { color: color('text'), children: live }) })) : null, shellView ? _jsx(ShellPane, { text: shellView, live: shellActive }) : null, phase === 'running' && stage ? _jsx(Activity, { stage: stage, tool: tool }) : null, phase === 'codeMode' ? (_jsx(CodeModePicker, { onPick: pickCodeMode, current: codeModeChosenRef.current ? codeMode : undefined, onCancel: codeModeChosenRef.current ? cancelCodeModePick : undefined })) : null, phase === 'mode' ? (_jsx(ModePicker, { onPick: pickMode, current: modeChosenRef.current ? mode : undefined, onCancel: modeChosenRef.current ? cancelModePick : undefined })) : null, phase === 'approval' && approval ? (_jsx(ApprovalPrompt, { approval: approval, allowAlways: isBatchable(approval.category), onChoice: answerApproval })) : null, phase === 'question' && question ? (question.options?.length ? (_jsx(ChoicePrompt, { prompt: question.prompt, options: question.options, onPick: answerQuestion })) : (_jsxs(Box, { flexDirection: "column", paddingX: 1, paddingY: 1, children: [_jsx(Text, { color: color('accent'), bold: true, children: question.prompt }), _jsxs(Box, { marginTop: 1, children: [_jsx(Text, { color: color('user'), children: '› ' }), _jsx(TextInput, { placeholder: "your answer", onSubmit: answerQuestion })] })] }))) : null, phase === 'hitl' && hitl ? (_jsx(ChoicePrompt, { prompt: hitl.tool ? `${hitl.tool} — ${hitl.message}` : hitl.message, options: hitl.options?.length ? hitl.options : ['Approve', 'Decline'], onPick: (value) => answerHitl(hitl.options?.length
|
|
491
521
|
? { approved: true, feedback: value }
|
|
492
522
|
: { approved: value === 'Approve' }) })) : null, showHelp && phase === 'ready' ? _jsx(HelpPanel, {}) : null, phase === 'ready' && !oneShot ? (_jsxs(_Fragment, { children: [_jsxs(Box, { paddingX: 1, children: [_jsx(Text, { color: color('user'), children: '› ' }), _jsx(TextInput, { defaultValue: inputSeed, placeholder: "What should Rivine build?", onChange: setInput, onSubmit: (value) => void submit(value) }, inputNonce)] }), _jsx(Completions, { matches: matches })] })) : null, phase === 'starting' ? _jsx(Activity, { stage: "Starting\u2026", tool: "" }) : null, _jsx(StatusBar, { info: {
|
|
493
523
|
mode,
|
package/dist/ui/components.js
CHANGED
|
@@ -40,13 +40,38 @@ export function Activity({ stage, tool }) {
|
|
|
40
40
|
const label = tool ? `${stage} · ${tool}` : stage;
|
|
41
41
|
return (_jsx(Box, { paddingX: 1, children: _jsx(Spinner, { label: fit(label, width - 6) }) }));
|
|
42
42
|
}
|
|
43
|
+
export function CodeModePicker({ onPick, current, onCancel, }) {
|
|
44
|
+
const width = terminalWidth();
|
|
45
|
+
const options = [
|
|
46
|
+
{ key: '1', mode: 'frontend', label: 'Frontend Only', detail: 'UI and client tier' },
|
|
47
|
+
{ key: '2', mode: 'backend', label: 'Backend Only', detail: 'server, API, and database' },
|
|
48
|
+
{ key: '3', mode: 'fullstack', label: 'Full Stack', detail: 'across every tier in one turn' },
|
|
49
|
+
{ key: '4', mode: 'simple', label: 'Plan/Ask Only', detail: 'plan, answer, document, refresh, ship' },
|
|
50
|
+
{ key: '5', mode: 'security', label: 'Security Scan/Fix', detail: 'find, verify, and patch' },
|
|
51
|
+
];
|
|
52
|
+
useInput((input, key) => {
|
|
53
|
+
if (key.escape && onCancel) {
|
|
54
|
+
onCancel();
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
if (key.return && current) {
|
|
58
|
+
onPick(current);
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
const found = options.find((o) => o.key === input);
|
|
62
|
+
if (found)
|
|
63
|
+
onPick(found.mode);
|
|
64
|
+
});
|
|
65
|
+
return (_jsxs(Box, { flexDirection: "column", paddingX: 1, paddingY: 1, children: [_jsx(Text, { color: color('text'), bold: true, children: "Mode \u2014 which agent should handle this?" }), _jsx(Box, { flexDirection: "column", marginTop: 1, children: options.map((o) => {
|
|
66
|
+
const active = o.mode === current;
|
|
67
|
+
return (_jsxs(Box, { children: [_jsx(Text, { color: active ? color('success') : color('faint'), children: active ? ' \u203a' : ' ' }), _jsx(Text, { color: color('accent'), children: o.key }), _jsx(Text, { color: color('faint'), children: " \u00B7 " }), _jsx(Text, { color: active ? color('text') : color('muted'), bold: active, children: o.label }), _jsx(Text, { color: color('faint'), children: " \u2014 " }), _jsx(Text, { color: color('faint'), children: fit(o.detail, Math.max(10, width - 34)) })] }, o.key));
|
|
68
|
+
}) }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { color: color('faint'), children: current ? 'esc keeps the current mode' : 'pick a number to begin' }) })] }));
|
|
69
|
+
}
|
|
43
70
|
export function ModePicker({ onPick, current, onCancel, }) {
|
|
44
71
|
const width = terminalWidth();
|
|
45
72
|
const options = [
|
|
46
|
-
{ key: '1', mode: '
|
|
47
|
-
{ key: '2', mode: '
|
|
48
|
-
{ key: '3', mode: 'acceptEdits', label: 'Accept edits', detail: 'writes apply, commands still ask' },
|
|
49
|
-
{ key: '4', mode: 'bypass', label: 'Full speed', detail: 'no prompts inside this project' },
|
|
73
|
+
{ key: '1', mode: 'default', label: 'Manual', detail: 'ask for approval before each edit' },
|
|
74
|
+
{ key: '2', mode: 'bypass', label: 'Bypass', detail: 'edit files in this project without asking' },
|
|
50
75
|
];
|
|
51
76
|
useInput((input, key) => {
|
|
52
77
|
if (key.escape && onCancel) {
|
|
@@ -54,14 +79,14 @@ export function ModePicker({ onPick, current, onCancel, }) {
|
|
|
54
79
|
return;
|
|
55
80
|
}
|
|
56
81
|
if (key.return) {
|
|
57
|
-
onPick(current ?? '
|
|
82
|
+
onPick(current ?? 'default');
|
|
58
83
|
return;
|
|
59
84
|
}
|
|
60
85
|
const found = options.find((o) => o.key === input);
|
|
61
86
|
if (found)
|
|
62
87
|
onPick(found.mode);
|
|
63
88
|
});
|
|
64
|
-
return (_jsxs(Box, { flexDirection: "column", paddingX: 1, paddingY: 1, children: [_jsx(Text, { color: color('text'), bold: true, children: "
|
|
89
|
+
return (_jsxs(Box, { flexDirection: "column", paddingX: 1, paddingY: 1, children: [_jsx(Text, { color: color('text'), bold: true, children: "Rate \u2014 how should Rivine apply edits?" }), _jsx(Box, { flexDirection: "column", marginTop: 1, children: options.map((o) => {
|
|
65
90
|
const active = o.mode === current;
|
|
66
91
|
return (_jsxs(Box, { children: [_jsx(Text, { color: active ? color('success') : color('faint'), children: active ? ' ›' : ' ' }), _jsx(Text, { color: color('accent'), children: o.key }), _jsx(Text, { color: color('faint'), children: " \u00B7 " }), _jsx(Text, { color: active ? color('text') : color('muted'), bold: active, children: o.label.padEnd(13) }), _jsx(Text, { color: color('muted'), children: fit(o.detail, Math.max(10, width - 26)) })] }, o.key));
|
|
67
92
|
}) }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { color: color('faint'), children: fit(current
|
package/dist/ui/theme.js
CHANGED
|
@@ -49,6 +49,10 @@ const GLYPHS_UNICODE = {
|
|
|
49
49
|
notice: '!',
|
|
50
50
|
error: '✗',
|
|
51
51
|
system: '◆',
|
|
52
|
+
'diff-add': ' ',
|
|
53
|
+
'diff-del': ' ',
|
|
54
|
+
'diff-hunk': ' ',
|
|
55
|
+
'diff-ctx': ' ',
|
|
52
56
|
};
|
|
53
57
|
const GLYPHS_ASCII = {
|
|
54
58
|
user: '>',
|
|
@@ -59,6 +63,10 @@ const GLYPHS_ASCII = {
|
|
|
59
63
|
notice: '!',
|
|
60
64
|
error: 'x',
|
|
61
65
|
system: '#',
|
|
66
|
+
'diff-add': ' ',
|
|
67
|
+
'diff-del': ' ',
|
|
68
|
+
'diff-hunk': ' ',
|
|
69
|
+
'diff-ctx': ' ',
|
|
62
70
|
};
|
|
63
71
|
export const ASCII_ONLY = process.env.RIVINE_ASCII === '1';
|
|
64
72
|
export function glyph(kind) {
|
|
@@ -73,6 +81,10 @@ const LINE_ROLE = {
|
|
|
73
81
|
notice: 'warn',
|
|
74
82
|
error: 'danger',
|
|
75
83
|
system: 'brand',
|
|
84
|
+
'diff-add': 'success',
|
|
85
|
+
'diff-del': 'danger',
|
|
86
|
+
'diff-hunk': 'brand',
|
|
87
|
+
'diff-ctx': 'faint',
|
|
76
88
|
};
|
|
77
89
|
export function lineColor(kind) {
|
|
78
90
|
return color(LINE_ROLE[kind]);
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@beunec/rivine",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Rivine Code in your terminal
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Rivine Code in your terminal \u2014 Beunec's software engineering runtime constructs, working on the files you already have open.",
|
|
5
5
|
"license": "UNLICENSED",
|
|
6
6
|
"author": "Beunec Technologies",
|
|
7
7
|
"homepage": "https://aselius.beunec.com",
|