@beunec/rivine 0.3.1 → 0.3.2

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.
@@ -11,6 +11,14 @@
11
11
  import { createHash } from 'node:crypto';
12
12
  import { MEDIATOR_MODE, TARGET_ENGINE, apiUrl } from '../config.js';
13
13
  import { accessToken } from '../auth/session.js';
14
+ export class TurnBusy extends Error {
15
+ detail;
16
+ constructor(detail) {
17
+ super(detail);
18
+ this.detail = detail;
19
+ this.name = 'TurnBusy';
20
+ }
21
+ }
14
22
  export class InsufficientCredits extends Error {
15
23
  detail;
16
24
  constructor(detail) {
@@ -101,6 +109,10 @@ export async function openTurn(req) {
101
109
  const body = (await res.json().catch(() => ({})));
102
110
  throw new InsufficientCredits(body.error ?? 'Not enough credits for Rivine Code.');
103
111
  }
112
+ if (res.status === 409) {
113
+ const body = (await res.json().catch(() => ({})));
114
+ throw new TurnBusy(String(body.detail ?? body.error ?? 'A turn is already in progress.'));
115
+ }
104
116
  if (!res.ok) {
105
117
  const body = await res.text().catch(() => '');
106
118
  throw new Error(`The engine refused this turn (HTTP ${res.status}). ${body.slice(0, 300)}`);
package/dist/index.js CHANGED
@@ -261,6 +261,9 @@ mcp
261
261
  function collect(value, previous) {
262
262
  return [...previous, value];
263
263
  }
264
+ function onBusyOption(value) {
265
+ return value === 'follow' || value === 'stop' ? value : 'fail';
266
+ }
264
267
  program
265
268
  .argument('[prompt...]', 'send one prompt and exit')
266
269
  .option('-p, --print <prompt>', 'send one prompt, print the answer, and exit')
@@ -270,6 +273,7 @@ program
270
273
  .option('--bypass', 'never ask for approval — for CI only, on a machine you are willing to have changed')
271
274
  .option('-C, --cwd <dir>', 'run against a different directory')
272
275
  .option('--output-format <format>', 'text | json — json puts one object on stdout and everything else on stderr', 'text')
276
+ .option('--on-busy <policy>', 'if an earlier turn on this project is still running: follow | stop | fail (default fail)')
273
277
  .action(async (prompt, options) => {
274
278
  const requested = options.cwd ? resolvePath(options.cwd) : process.cwd();
275
279
  const cwd = await realpath(requested).catch(() => requested);
@@ -297,7 +301,7 @@ program
297
301
  process.exitCode = 2;
298
302
  return;
299
303
  }
300
- process.exitCode = await runHeadless({ cwd, mode, codeMode, prompt: oneShot, format });
304
+ process.exitCode = await runHeadless({ cwd, mode, codeMode, prompt: oneShot, format, onBusy: onBusyOption(options.onBusy) });
301
305
  return;
302
306
  }
303
307
  if (format === 'json' && oneShot) {
@@ -308,7 +312,7 @@ program
308
312
  process.exitCode = 2;
309
313
  return;
310
314
  }
311
- process.exitCode = await runHeadless({ cwd, mode, codeMode, prompt: oneShot, format });
315
+ process.exitCode = await runHeadless({ cwd, mode, codeMode, prompt: oneShot, format, onBusy: onBusyOption(options.onBusy) });
312
316
  return;
313
317
  }
314
318
  const { waitUntilExit } = render(React.createElement(App, {
@@ -8,14 +8,42 @@
8
8
  * accompanying it; it is not open source and is not licensed for
9
9
  * redistribution or derivative works.
10
10
  */
11
- import { spawnSync } from 'node:child_process';
12
- import { mkdir, readFile, readdir, realpath, stat, writeFile, } from 'node:fs/promises';
11
+ import { execFile, spawnSync } from 'node:child_process';
12
+ import { mkdir, readFile, readdir, realpath, stat, unlink, writeFile, } from 'node:fs/promises';
13
+ import { tmpdir } from 'node:os';
13
14
  import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
15
+ import { promisify } from 'node:util';
14
16
  import { runShell } from './shell.js';
15
17
  import { applyEdit, describeFailure, writeFileAtomic } from './edit.js';
16
18
  import { decide } from './permissions.js';
17
19
  const VIRTUAL_ROOT = '/workspace';
18
20
  const MAX_READ_BYTES = 2 * 1024 * 1024;
21
+ const IMAGE_FILE = /\.(png|jpe?g|gif|webp|heic|heif|bmp|tiff?)$/i;
22
+ const IMAGE_CONVERT_ALWAYS = /\.(heic|heif|bmp|tiff?)$/i;
23
+ const IMAGE_SEND_BYTES = 1_500_000;
24
+ const execFileP = promisify(execFile);
25
+ export async function shrinkImage(path) {
26
+ const out = join(tmpdir(), `rivine-img-${process.pid}-${Date.now()}.jpg`);
27
+ const attempts = [];
28
+ if (process.platform === 'darwin')
29
+ attempts.push(['sips', ['-s', 'format', 'jpeg', '-Z', '1600', path, '--out', out]]);
30
+ attempts.push(['magick', [path, '-resize', '1600x1600>', '-quality', '85', out]]);
31
+ attempts.push(['convert', [path, '-resize', '1600x1600>', '-quality', '85', out]]);
32
+ for (const [cmd, args] of attempts) {
33
+ try {
34
+ await execFileP(cmd, args, { timeout: 30_000 });
35
+ const buf = await readFile(out);
36
+ if (buf.length)
37
+ return buf;
38
+ }
39
+ catch {
40
+ }
41
+ finally {
42
+ await unlink(out).catch(() => undefined);
43
+ }
44
+ }
45
+ return null;
46
+ }
19
47
  const SKIP_DIRS = new Set([
20
48
  'node_modules', '.git', 'dist', 'build', '.next', '.turbo', '.venv', 'venv',
21
49
  '__pycache__', '.mypy_cache', '.pytest_cache', 'target', '.cache', 'coverage',
@@ -307,6 +335,12 @@ export async function execute(root, request, hooks = {}) {
307
335
  const info = await stat(target.absolute);
308
336
  if (info.isDirectory())
309
337
  return { ok: false, error: 'is_directory' };
338
+ if (IMAGE_FILE.test(target.absolute)
339
+ && (info.size > IMAGE_SEND_BYTES || IMAGE_CONVERT_ALWAYS.test(target.absolute))) {
340
+ const small = await shrinkImage(target.absolute);
341
+ if (small)
342
+ return { ok: true, data: { content: small.toString('base64'), encoding: 'base64' } };
343
+ }
310
344
  if (info.size > MAX_READ_BYTES) {
311
345
  return {
312
346
  ok: false,
@@ -104,6 +104,96 @@ const ALWAYS_ASK_COMMANDS = [
104
104
  { pattern: /curl[^|]*\|\s*(sudo\s+)?(ba)?sh|wget[^|]*\|\s*(sudo\s+)?(ba)?sh/, reason: 'pipes a download into a shell' },
105
105
  { pattern: /:\(\)\s*\{.*\|.*&.*\}/, reason: 'fork bomb' },
106
106
  ];
107
+ const IRREVERSIBLE_COMMANDS = [
108
+ { pattern: /\bgit\s+push\b[^\n;&|]*(--force\b|--force-with-lease\b|\s-f\b|\s\+[\w./-]+)/, reason: 'force-pushes, rewriting history others may have' },
109
+ { pattern: /\bgit\s+push\b[^\n;&|]*(--delete\b|\s-d\b|\s:[\w./-]+)/, reason: 'deletes a remote branch' },
110
+ { pattern: /\bgh\s+repo\s+delete\b/, reason: 'deletes a GitHub repository' },
111
+ { pattern: /\baws\s+[\w-]+\s+(delete|terminate|remove|purge|deregister|disable-key|schedule-key-deletion)[\w-]*/, reason: 'deletes or disables AWS resources' },
112
+ { pattern: /\baws\s+s3\s+(rm|rb)\b/, reason: 'deletes S3 objects or buckets' },
113
+ { pattern: /\baws\s+s3\s+sync\b[^\n;&|]*--delete\b/, reason: 'deletes S3 objects missing from the source' },
114
+ { pattern: /\bgcloud\b[^\n;&|]*\s(delete|destroy)\b/, reason: 'deletes Google Cloud resources' },
115
+ { pattern: /\bwrangler\s+(delete\b|secret\s+(put|delete|bulk)\b|r2\s+(bucket|object)\s+delete\b|kv\s+\S+\s+delete\b|d1\s+delete\b|queues\s+delete\b)/, reason: 'deletes Cloudflare resources or writes a secret' },
116
+ { pattern: /\bsupabase\s+(db\s+(reset|push)\b|projects\s+delete\b|branches\s+delete\b)/, reason: 'resets, overwrites or deletes a Supabase database or project' },
117
+ { pattern: /\bterraform\s+(destroy\b|apply\b[^\n;&|]*-destroy\b)/, reason: 'destroys infrastructure' },
118
+ { pattern: /\bkubectl\s+delete\b/, reason: 'deletes cluster resources' },
119
+ { pattern: /\bheroku\s+(apps:destroy|pg:reset)\b|\bfly\s+(apps\s+destroy|volumes\s+destroy)\b|\bvercel\s+remove\b/, reason: 'destroys a hosted app or its data' },
120
+ { pattern: /\b(DROP\s+(TABLE|DATABASE|SCHEMA|ROLE)|TRUNCATE\s+(TABLE\s+)?[\w."]+)/i, reason: 'drops or empties database data' },
121
+ { pattern: /\bDELETE\s+FROM\s+[\w."]+\s*(;|'|"|$)/i, reason: 'deletes every row of a table' },
122
+ { pattern: /\bprisma\s+(migrate\s+reset\b|db\s+push\b[^\n;&|]*--force-reset\b)/, reason: 'wipes the database' },
123
+ { pattern: /\b(redis-cli|valkey-cli)\b[^\n;&|]*\b(FLUSHALL|FLUSHDB)\b/i, reason: 'empties a Redis database' },
124
+ { pattern: /\bdocker\s+(volume\s+(rm|prune)\b|system\s+prune\b[^\n;&|]*(\s-a\b|--all\b|--volumes\b))/, reason: 'deletes Docker volumes (data)' },
125
+ { pattern: /\bdocker(-compose|\s+compose)\s+down\b[^\n;&|]*(\s-v\b|--volumes\b)/, reason: 'deletes Docker Compose volumes (data)' },
126
+ { pattern: /\bmkfs(\.\w+)?\b|\bdd\s+[^\n;&|]*\bof=\/dev\//, reason: 'overwrites a disk' },
127
+ { pattern: /\bfind\s+(\/|~|\$HOME)\S*\s[^\n;&|]*-delete\b/, reason: 'deletes files across the disk or home folder' },
128
+ ];
129
+ const DANGEROUS_RM_EXACT = new Set([
130
+ '/', '/*', '~', '~/', '~/*', '$HOME', '$HOME/', '${HOME}', '${HOME}/', '.', './', '*', './*',
131
+ '/workspace', '/workspace/', '/workspace/*',
132
+ ]);
133
+ const SYSTEM_DIRS = /^\/(etc|usr|bin|sbin|var|lib|lib64|boot|dev|proc|sys|opt|srv|root|System|Library|Applications|private|Volumes|home|Users)(\/[^/]*)?\/?$/;
134
+ const HOME_CLEANUP = /^(~|\$HOME|\$\{HOME\})\/(\.cache|\.npm|\.pnpm-store|\.yarn\/cache|\.cargo\/registry|\.gradle\/caches|\.m2\/repository|Library\/Caches|Library\/Developer\/Xcode\/DerivedData|Library\/Logs|\.Trash)(\/|$)/;
135
+ const RM_RECURSIVE_OR_FORCE = /^-[a-zA-Z]*[rRf][a-zA-Z]*$|^--(recursive|force)$/;
136
+ const SEGMENT_SPLIT = /\|\||&&|[;|\n]/;
137
+ function words(segment) {
138
+ const out = [];
139
+ const re = /'([^']*)'|"([^"]*)"|(\S+)/g;
140
+ let m;
141
+ while ((m = re.exec(segment)) !== null)
142
+ out.push(m[1] ?? m[2] ?? m[3] ?? '');
143
+ return out;
144
+ }
145
+ function rmProblem(command) {
146
+ for (const segment of command.split(SEGMENT_SPLIT)) {
147
+ let argv = words(segment);
148
+ while (argv.length && (['sudo', 'command', 'nohup', 'time'].includes(argv[0]) || argv[0].includes('='))) {
149
+ argv = argv.slice(1);
150
+ }
151
+ if (!argv.length || argv[0].replace(/^.*\//, '') !== 'rm')
152
+ continue;
153
+ const args = argv.slice(1);
154
+ if (!args.some((a) => a.startsWith('-') && RM_RECURSIVE_OR_FORCE.test(a)))
155
+ continue;
156
+ for (const target of args.filter((a) => !a.startsWith('-'))) {
157
+ const t = target.replace(/\/+$/, '') || '/';
158
+ if (DANGEROUS_RM_EXACT.has(target) || DANGEROUS_RM_EXACT.has(t) || SYSTEM_DIRS.test(t) || t === '..' || t.startsWith('../')) {
159
+ return `deletes '${target}', a whole disk, home folder, project or system folder`;
160
+ }
161
+ if (/^(~|\$HOME|\$\{HOME\})(\/|$)/.test(t) && !HOME_CLEANUP.test(t)) {
162
+ return `deletes '${target}' in the home folder (only caches there are cleanup)`;
163
+ }
164
+ }
165
+ }
166
+ return null;
167
+ }
168
+ export function irreversibleCommand(command) {
169
+ for (const { pattern, reason } of IRREVERSIBLE_COMMANDS) {
170
+ if (pattern.test(command))
171
+ return reason;
172
+ }
173
+ return rmProblem(command);
174
+ }
175
+ const SECRET_READERS = new Set([
176
+ 'cat', 'head', 'tail', 'less', 'more', 'grep', 'rg', 'ag', 'sed', 'awk', 'bat', 'strings', 'xxd', 'od',
177
+ 'base64', 'cp', 'jq', 'source', '.', 'sort', 'cut', 'tac', 'nl',
178
+ ]);
179
+ export function commandReadsSecret(command) {
180
+ for (const segment of command.split(SEGMENT_SPLIT)) {
181
+ const argv = words(segment.trim());
182
+ if (!argv.length)
183
+ continue;
184
+ const head = argv[0].replace(/^.*\//, '');
185
+ if ((head === 'env' && argv.length === 1) || head === 'printenv') {
186
+ return 'prints every environment variable, which usually include credentials';
187
+ }
188
+ if (!SECRET_READERS.has(head))
189
+ continue;
190
+ const secret = argv.slice(1).find((a) => !a.startsWith('-') && isSecretPath(a.replace(/^\.\//, '')));
191
+ if (secret)
192
+ return `${secret} looks like it holds credentials — reading it would send them to the model`;
193
+ }
194
+ return null;
195
+ }
196
+ const ALLOW_DESTRUCTIVE = Boolean(process.env.RIVINE_ALLOW_DESTRUCTIVE);
107
197
  const SHELL_CHAINING = /[;&|]|\$\(|`|>>|>|<\(/;
108
198
  const AGENT_METADATA_DIR = /^\.rivine(\/|$)/;
109
199
  export function isAgentMetadataPath(relPath) {
@@ -187,6 +277,20 @@ export function decide(input) {
187
277
  reason: `${relPath ?? 'this path'} is outside the project directory`,
188
278
  };
189
279
  }
280
+ if (op === 'execute' && command) {
281
+ const irreversible = irreversibleCommand(command);
282
+ if (irreversible && !(mode === 'bypass' && ALLOW_DESTRUCTIVE)) {
283
+ return {
284
+ verdict: 'ask',
285
+ category: 'destructive',
286
+ reason: `${irreversible} — this cannot be undone` +
287
+ (mode === 'bypass' ? ' (set RIVINE_ALLOW_DESTRUCTIVE=1 to allow these without asking)' : ''),
288
+ };
289
+ }
290
+ const secretRead = commandReadsSecret(command);
291
+ if (secretRead)
292
+ return { verdict: 'ask', category: 'secret', reason: secretRead };
293
+ }
190
294
  if (mode === 'bypass')
191
295
  return { verdict: 'allow' };
192
296
  if (relPath && isSecretPath(relPath)) {
@@ -8,7 +8,7 @@
8
8
  * accompanying it; it is not open source and is not licensed for
9
9
  * redistribution or derivative works.
10
10
  */
11
- import { cancelTurn, closeSession, getTurnStatus, openTurn, postFsResult, resumeTurn } from '../api/workspace.js';
11
+ import { cancelTurn, closeSession, getTurnStatus, openTurn, postFsResult, resumeTurn, TurnBusy } from '../api/workspace.js';
12
12
  import { apiUrl } from '../config.js';
13
13
  import { accessToken } from '../auth/session.js';
14
14
  import { classify, execute } from './fsExecutor.js';
@@ -19,6 +19,11 @@ import { readTurnStream } from './stream.js';
19
19
  import { displayStage } from '../ui/render.js';
20
20
  const PENDING_POLL_MS = 1200;
21
21
  const FOLLOW_POLL_MS = Number(process.env.RIVINE_FOLLOW_POLL_MS) || 3000;
22
+ export function busyChoice(policy, message) {
23
+ if (policy !== 'auto')
24
+ return policy;
25
+ return /^\s*(continue|go on|keep going|resume|carry on)\b[\s.!]*$/i.test(message) ? 'follow' : 'stop';
26
+ }
22
27
  export function writtenPaths(request) {
23
28
  const args = (request.args ?? {});
24
29
  let paths = [];
@@ -105,7 +110,7 @@ export class TurnRunner {
105
110
  (selection.skipped.length ? ` (not needed: ${selection.skipped.join(', ')})` : ''));
106
111
  }
107
112
  try {
108
- const response = await openTurn({
113
+ const request = {
109
114
  workspaceId: this.ctx.workspaceId,
110
115
  chatSpaceId: this.ctx.chatSpaceId,
111
116
  sessionId: this.ctx.sessionId,
@@ -120,7 +125,31 @@ export class TurnRunner {
120
125
  input_schema,
121
126
  })),
122
127
  sessionKey: this.key.toBase64(),
123
- });
128
+ };
129
+ let response;
130
+ try {
131
+ response = await openTurn(request);
132
+ }
133
+ catch (err) {
134
+ if (!(err instanceof TurnBusy))
135
+ throw err;
136
+ const choice = busyChoice(this.ctx.onBusy ?? 'auto', message);
137
+ if (choice === 'fail') {
138
+ return {
139
+ answer: '',
140
+ cancelled: false,
141
+ error: `${err.detail} Re-run with --on-busy follow to wait for it, or --on-busy stop to stop it and send this.`,
142
+ };
143
+ }
144
+ if (choice === 'follow') {
145
+ this.cb.onNotice('Your previous request is still running on the server — following it.');
146
+ return await this.follow('');
147
+ }
148
+ this.cb.onNotice('Your previous request is still running on the server — stopping it and sending this one.');
149
+ await cancelTurn(this.ctx.workspaceId, this.ctx.chatSpaceId);
150
+ await this.waitUntilIdle();
151
+ response = await openTurn(request);
152
+ }
124
153
  return await this.consume(response);
125
154
  }
126
155
  finally {
@@ -130,85 +159,110 @@ export class TurnRunner {
130
159
  await Promise.allSettled([...this.inFlight]);
131
160
  }
132
161
  }
162
+ async waitUntilIdle(maxMs = 30_000) {
163
+ const until = Date.now() + maxMs;
164
+ while (Date.now() < until) {
165
+ try {
166
+ const status = await getTurnStatus(this.ctx.workspaceId, this.ctx.chatSpaceId);
167
+ if (status.status !== 'running')
168
+ return;
169
+ }
170
+ catch {
171
+ }
172
+ await new Promise((r) => setTimeout(r, 1500));
173
+ }
174
+ }
175
+ async stop() {
176
+ this.stopped = true;
177
+ await cancelTurn(this.ctx.workspaceId, this.ctx.chatSpaceId);
178
+ }
133
179
  async consume(response) {
134
180
  let answer = '';
135
181
  let pendingHitl = null;
136
182
  let continuing = false;
137
- for await (const event of readTurnStream(response)) {
138
- switch (event.type) {
139
- case 'token':
140
- answer += event.content ?? '';
141
- this.cb.onToken(event.content ?? '');
142
- break;
143
- case 'stage': {
144
- const label = displayStage(String(event.label ?? ''));
145
- if (label)
146
- this.cb.onStage(label);
147
- break;
148
- }
149
- case 'tool_call_start': {
150
- const e = event;
151
- const detail = e.args?.command ?? e.args?.filename ?? '';
152
- this.cb.onTool(String(e.name ?? 'tool'), String(detail));
153
- break;
154
- }
155
- case 'file_diff': {
156
- const e = event;
157
- const path = String(e.path ?? '').trim();
158
- if (!path)
183
+ try {
184
+ for await (const event of readTurnStream(response)) {
185
+ switch (event.type) {
186
+ case 'token':
187
+ answer += event.content ?? '';
188
+ this.cb.onToken(event.content ?? '');
159
189
  break;
160
- this.cb.onFileDiff?.({
161
- path: path.replace(/^\/workspace\/?/, ''),
162
- stat: String(e.stat ?? ''),
163
- patch: String(e.patch ?? ''),
164
- added: Number(e.added ?? 0),
165
- removed: Number(e.removed ?? 0),
166
- isNew: Boolean(e.is_new),
167
- isDelete: Boolean(e.is_delete),
168
- binary: Boolean(e.binary),
169
- });
170
- break;
171
- }
172
- case 'code_file': {
173
- const e = event;
174
- const path = String(e.path ?? '').trim();
175
- const rel = path.replace(/^\/workspace\/?/, '');
176
- if (!rel || rel === '.' || rel.endsWith('/'))
190
+ case 'stage': {
191
+ const label = displayStage(String(event.label ?? ''));
192
+ if (label)
193
+ this.cb.onStage(label);
194
+ break;
195
+ }
196
+ case 'tool_call_start': {
197
+ const e = event;
198
+ const detail = e.args?.command ?? e.args?.filename ?? '';
199
+ this.cb.onTool(String(e.name ?? 'tool'), String(detail));
200
+ break;
201
+ }
202
+ case 'file_diff': {
203
+ const e = event;
204
+ const path = String(e.path ?? '').trim();
205
+ if (!path)
206
+ break;
207
+ this.cb.onFileDiff?.({
208
+ path: path.replace(/^\/workspace\/?/, ''),
209
+ stat: String(e.stat ?? ''),
210
+ patch: String(e.patch ?? ''),
211
+ added: Number(e.added ?? 0),
212
+ removed: Number(e.removed ?? 0),
213
+ isNew: Boolean(e.is_new),
214
+ isDelete: Boolean(e.is_delete),
215
+ binary: Boolean(e.binary),
216
+ });
217
+ break;
218
+ }
219
+ case 'code_file': {
220
+ const e = event;
221
+ const path = String(e.path ?? '').trim();
222
+ const rel = path.replace(/^\/workspace\/?/, '');
223
+ if (!rel || rel === '.' || rel.endsWith('/'))
224
+ break;
225
+ this.cb.onFileTouched(path, e.status);
226
+ break;
227
+ }
228
+ case 'fs_request':
229
+ this.dispatch(event);
230
+ break;
231
+ case 'approval_request': {
232
+ const e = event;
233
+ pendingHitl = {
234
+ message: String(e.message ?? 'Approval required.'),
235
+ tool: e.tool ? String(e.tool) : undefined,
236
+ options: extractOptions(e),
237
+ multi: Boolean(e.ui_schema?.multi ?? e.args?.multi),
238
+ threadId: String(e.thread_id ?? `${this.ctx.workspaceId}_${this.ctx.chatSpaceId}`),
239
+ kind: hitlKind(e.hitl_type, e.args),
240
+ };
241
+ break;
242
+ }
243
+ case 'final_answer':
244
+ answer = String(event.content ?? answer);
245
+ break;
246
+ case 'turn_continuing':
247
+ continuing = true;
248
+ break;
249
+ case 'error': {
250
+ const e = event;
251
+ return { answer, cancelled: Boolean(e.cancelled), error: String(e.content ?? 'Unknown error') };
252
+ }
253
+ case 'done':
254
+ break;
255
+ default:
177
256
  break;
178
- this.cb.onFileTouched(path, e.status);
179
- break;
180
- }
181
- case 'fs_request':
182
- this.dispatch(event);
183
- break;
184
- case 'approval_request': {
185
- const e = event;
186
- pendingHitl = {
187
- message: String(e.message ?? 'Approval required.'),
188
- tool: e.tool ? String(e.tool) : undefined,
189
- options: extractOptions(e),
190
- multi: Boolean(e.ui_schema?.multi ?? e.args?.multi),
191
- threadId: String(e.thread_id ?? `${this.ctx.workspaceId}_${this.ctx.chatSpaceId}`),
192
- kind: hitlKind(e.hitl_type, e.args),
193
- };
194
- break;
195
- }
196
- case 'final_answer':
197
- answer = String(event.content ?? answer);
198
- break;
199
- case 'turn_continuing':
200
- continuing = true;
201
- break;
202
- case 'error': {
203
- const e = event;
204
- return { answer, cancelled: Boolean(e.cancelled), error: String(e.content ?? 'Unknown error') };
205
257
  }
206
- case 'done':
207
- break;
208
- default:
209
- break;
210
258
  }
211
259
  }
260
+ catch (err) {
261
+ if (this.stopped)
262
+ throw err;
263
+ this.cb.onNotice(`The connection to this turn dropped (${err.message}); following it on the server…`);
264
+ return this.follow(answer);
265
+ }
212
266
  if (pendingHitl) {
213
267
  const settled = await this.settleHitl(pendingHitl, answer);
214
268
  if (isFollowSignal(settled))
@@ -104,8 +104,8 @@ function shellInvocation(command) {
104
104
  }
105
105
  return { file: process.env.SHELL || '/bin/sh', args: ['-c', command] };
106
106
  }
107
- function agentEnv() {
108
- return { ...process.env, GIT_PAGER: 'cat', PAGER: 'cat', GIT_TERMINAL_PROMPT: '0' };
107
+ export function agentEnv() {
108
+ return { ...process.env, CI: process.env.CI || '1', GIT_PAGER: 'cat', PAGER: 'cat', GIT_TERMINAL_PROMPT: '0' };
109
109
  }
110
110
  function runWithPty(pty, command, options) {
111
111
  const { file, args } = shellInvocation(command);
@@ -59,6 +59,7 @@ export async function runHeadless(options) {
59
59
  root: options.cwd,
60
60
  mode: options.mode,
61
61
  codeMode: options.codeMode,
62
+ onBusy: options.onBusy ?? 'fail',
62
63
  }, {
63
64
  onToken: (text) => out.token(text),
64
65
  onStage: (label) => out.status(label),
package/dist/ui/App.js CHANGED
@@ -213,6 +213,10 @@ export function App({ cwd, initialMode, askForMode, initialCodeMode, oneShot })
213
213
  push('system', `Rate: ${describeMode('bypass')}`);
214
214
  push('notice', 'Accept-edits is gone — Bypass applies in-project edits without asking.');
215
215
  return true;
216
+ case '/stop':
217
+ push('system', 'Stopping any turn still running on the server…');
218
+ void runnerRef.current?.stop().then(() => push('system', 'Stopped.'));
219
+ return true;
216
220
  case '/credits':
217
221
  openBrowser(`${siteUrl()}/credits`);
218
222
  push('system', `Opened ${siteUrl()}/credits`);
@@ -536,6 +540,11 @@ export function App({ cwd, initialMode, askForMode, initialCodeMode, oneShot })
536
540
  push('notice', 'Stopped the running command.');
537
541
  return;
538
542
  }
543
+ if (key.escape && phase === 'running') {
544
+ push('notice', 'Stopping this turn…');
545
+ void runnerRef.current?.stop();
546
+ return;
547
+ }
539
548
  if (key.ctrl && ch === 'c')
540
549
  quit();
541
550
  }, { isActive: phase === 'ready' || phase === 'running' });
@@ -73,7 +73,11 @@ export class HeadlessRenderer {
73
73
  refuseApproval(approval) {
74
74
  const fix = this.mode === 'plan'
75
75
  ? 'plan mode makes no changes; re-run without --plan'
76
- : 'no terminal to ask on — re-run with --rate bypass';
76
+ : approval.category === 'destructive'
77
+ ? 'cannot be undone and there is no terminal to confirm on — run it yourself, or set RIVINE_ALLOW_DESTRUCTIVE=1'
78
+ : approval.category === 'secret'
79
+ ? 'would send credentials to the model and there is no terminal to confirm on'
80
+ : 'no terminal to ask on — re-run with --rate bypass';
77
81
  this.refused += 1;
78
82
  this.line('notice', `Skipped: ${approval.summary}`);
79
83
  this.line('notice', ` ${approval.reason} (${fix})`);
@@ -30,6 +30,11 @@ export const SLASH_COMMANDS = [
30
30
  summary: 'switch Rate to Bypass',
31
31
  detail: 'Accept-edits is gone; Bypass applies in-project edits without asking.',
32
32
  },
33
+ {
34
+ name: '/stop',
35
+ summary: 'stop a turn still running on the server',
36
+ detail: 'While a turn runs, Esc does the same (when no command is running; Esc first stops the command).',
37
+ },
33
38
  { name: '/status', summary: 'account, project, Mode, Rate and balance' },
34
39
  { name: '/diff', summary: 'what changed in the working tree' },
35
40
  {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@beunec/rivine",
3
- "version": "0.3.1",
3
+ "version": "0.3.2",
4
4
  "description": "Rivine Code in your terminal — Beunec's software engineering runtime constructs, working on the files you already have open.",
5
5
  "license": "UNLICENSED",
6
6
  "author": "Beunec Technologies",