@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.
- package/dist/api/workspace.js +12 -0
- package/dist/index.js +6 -2
- package/dist/protocol/fsExecutor.js +36 -2
- package/dist/protocol/permissions.js +104 -0
- package/dist/protocol/runner.js +127 -73
- package/dist/protocol/shell.js +2 -2
- package/dist/runHeadless.js +1 -0
- package/dist/ui/App.js +9 -0
- package/dist/ui/headless.js +5 -1
- package/dist/ui/slashCommands.js +5 -0
- package/package.json +1 -1
package/dist/api/workspace.js
CHANGED
|
@@ -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)) {
|
package/dist/protocol/runner.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
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))
|
package/dist/protocol/shell.js
CHANGED
|
@@ -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);
|
package/dist/runHeadless.js
CHANGED
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' });
|
package/dist/ui/headless.js
CHANGED
|
@@ -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
|
-
:
|
|
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})`);
|
package/dist/ui/slashCommands.js
CHANGED
|
@@ -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.
|
|
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",
|