@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 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; `rivine status` is where you look if
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
- ## Permission modes
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
- | Mode | Applies without asking | Press |
86
+ | Rate | Applies without asking | How to choose |
60
87
  |---|---|---|
61
- | `plan` | Reads, and shell commands that provably only read | Shift+Tab |
62
- | `default` | Reads. Asks before any write, any command, any delete | Shift+Tab |
63
- | `acceptEdits` | Writes inside the project. Still asks before commands | Shift+Tab |
64
- | `bypass` | Everything. For CI | `--bypass` only |
65
-
66
- Start in a mode with `--plan`, `-m acceptEdits`, or `--bypass`. Shift+Tab
67
- cycles the first three during a session. **`bypass` is not in the cycle** —
68
- you cannot arrive at it by pressing a key one time too many.
69
-
70
- Four things ask in every mode except `bypass`, however permissive the mode
71
- otherwise is:
72
-
73
- - **Credentials.** `.env`, `*.pem`, `~/.ssh`, `~/.aws`, service-account JSON.
74
- These ask on *read* too — reading one puts a live secret into a model prompt.
75
- - **Anything outside the project.** The jail resolves symlinks before deciding,
76
- so a link pointing out of the directory is caught rather than followed.
77
- - **Commands that are hard to undo.** `rm -rf`, `git push`, `--force`,
78
- `git reset --hard`, `sudo`, `npm publish`, any deploy, `terraform apply`.
79
- - **Batch writes.** Judged by their most dangerous entry, so a safe first file
80
- cannot clear a whole batch.
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 --plan start read-only
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`. Shift+Tab changes
109
- permission mode; Ctrl+C quits cleanly.
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" -m acceptEdits
143
- rivine -p "..." --bypass # CI, on a machine you accept changes to
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, and `3`
266
- nothing could be done — every operation needed approval and there was no
267
- terminal to give it. `3` exists because that run still costs a turn: without
268
- it the step would go green having changed nothing. The result object carries
269
- `refused` alongside `files_written` so a workflow can tell the two apart.
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
- **$10.00** in credits to start a turn; below that the CLI says so and links to
320
- the top-up page rather than starting work it cannot finish. `rivine status` and
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
- Permission mode. `bypass` is the default because there is nobody to
42
- approve anything; every other mode refuses operations that would prompt.
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: a mode is one of four
119
- # words, and anything else is a mistake worth stopping on rather than
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
- plan|default|acceptEdits|bypass) ;;
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 mode '$RIVINE_MODE'. Use plan, default, acceptEdits or bypass."
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
@@ -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 { PERMISSION_MODES } from './protocol/permissions.js';
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: 'plan', explicit: true };
49
- const requested = options.mode;
50
- if (requested && PERMISSION_MODES.includes(requested))
51
- return { mode: requested, explicit: true };
52
- return { mode: 'default', explicit: false };
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>', `permission mode: ${PERMISSION_MODES.join(' | ')}`)
261
- .option('--plan', 'start in plan mode (reads only)')
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
- process.exitCode = await runHeadless({ cwd, mode, prompt: oneShot, format });
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({ cwd, mode, prompt: oneShot, format });
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
- const index = CYCLE_MODES.indexOf(current);
213
- if (index === -1)
214
- return 'default';
215
- return CYCLE_MODES[(index + 1) % CYCLE_MODES.length];
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) {
@@ -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();
@@ -57,6 +57,7 @@ export async function runHeadless(options) {
57
57
  sessionId: session.bootstrap.session_id,
58
58
  root: options.cwd,
59
59
  mode: options.mode,
60
+ codeMode: options.codeMode,
60
61
  }, {
61
62
  onToken: (text) => out.token(text),
62
63
  onStage: (label) => out.status(label),
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 [phase, setPhase] = useState(askForMode ? 'mode' : 'starting');
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,
@@ -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: 'plan', label: 'Plan', detail: 'reads and answers, changes nothing' },
47
- { key: '2', mode: 'default', label: 'Ask me', detail: 'approve each write and command' },
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 ?? 'acceptEdits');
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: "How should Rivine work in this project?" }), _jsx(Box, { flexDirection: "column", marginTop: 1, children: options.map((o) => {
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.1.5",
4
- "description": "Rivine Code in your terminal — Beunec's software engineering runtime constructs, working on the files you already have open.",
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",