@beunec/rivine 0.2.1 → 0.3.1

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.
@@ -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 { closeSession, openTurn, postFsResult, resumeTurn } from '../api/workspace.js';
11
+ import { cancelTurn, closeSession, getTurnStatus, openTurn, postFsResult, resumeTurn } 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';
@@ -16,7 +16,21 @@ import { isSealedEnvelope, SessionKey } from './sessionKey.js';
16
16
  import { permissionModeToRate } from './permissions.js';
17
17
  import { selectRelevantTools } from '../mcp/relevance.js';
18
18
  import { readTurnStream } from './stream.js';
19
+ import { displayStage } from '../ui/render.js';
19
20
  const PENDING_POLL_MS = 1200;
21
+ const FOLLOW_POLL_MS = Number(process.env.RIVINE_FOLLOW_POLL_MS) || 3000;
22
+ export function writtenPaths(request) {
23
+ const args = (request.args ?? {});
24
+ let paths = [];
25
+ if (request.op === 'write' || request.op === 'edit')
26
+ paths = [String(args.file_path ?? args.path ?? '')];
27
+ else if (request.op === 'upload')
28
+ paths = (args.files ?? []).map((f) => String(f?.path ?? ''));
29
+ return paths.filter((p) => p && !/(^|\/)\.rivine\//.test(p));
30
+ }
31
+ function isFollowSignal(value) {
32
+ return value.follow === true;
33
+ }
20
34
  export class TurnRunner {
21
35
  ctx;
22
36
  cb;
@@ -26,6 +40,8 @@ export class TurnRunner {
26
40
  approvalChain = Promise.resolve();
27
41
  inFlight = new Set();
28
42
  stopped = false;
43
+ turnActive = false;
44
+ following = false;
29
45
  pollTimer = null;
30
46
  constructor(ctx, cb, mcp) {
31
47
  this.ctx = ctx;
@@ -72,7 +88,16 @@ export class TurnRunner {
72
88
  this.ctx.mcpTools = tools;
73
89
  }
74
90
  async run(message, snapshot) {
91
+ if (!this.ctx.codeMode) {
92
+ return {
93
+ answer: '',
94
+ cancelled: false,
95
+ error: 'No Mode selected. Pick one with /mode — Frontend Only, Backend Only, ' +
96
+ 'Full Stack, Plan/Ask Only, or Security Scan/Fix.',
97
+ };
98
+ }
75
99
  this.stopped = false;
100
+ this.turnActive = true;
76
101
  this.startPolling();
77
102
  const selection = selectRelevantTools(message, this.ctx.mcpTools ?? []);
78
103
  if (selection.servers.length) {
@@ -99,6 +124,8 @@ export class TurnRunner {
99
124
  return await this.consume(response);
100
125
  }
101
126
  finally {
127
+ this.turnActive = false;
128
+ this.following = false;
102
129
  this.stopPolling();
103
130
  await Promise.allSettled([...this.inFlight]);
104
131
  }
@@ -106,15 +133,19 @@ export class TurnRunner {
106
133
  async consume(response) {
107
134
  let answer = '';
108
135
  let pendingHitl = null;
136
+ let continuing = false;
109
137
  for await (const event of readTurnStream(response)) {
110
138
  switch (event.type) {
111
139
  case 'token':
112
140
  answer += event.content ?? '';
113
141
  this.cb.onToken(event.content ?? '');
114
142
  break;
115
- case 'stage':
116
- this.cb.onStage(String(event.label ?? ''));
143
+ case 'stage': {
144
+ const label = displayStage(String(event.label ?? ''));
145
+ if (label)
146
+ this.cb.onStage(label);
117
147
  break;
148
+ }
118
149
  case 'tool_call_start': {
119
150
  const e = event;
120
151
  const detail = e.args?.command ?? e.args?.filename ?? '';
@@ -158,12 +189,16 @@ export class TurnRunner {
158
189
  options: extractOptions(e),
159
190
  multi: Boolean(e.ui_schema?.multi ?? e.args?.multi),
160
191
  threadId: String(e.thread_id ?? `${this.ctx.workspaceId}_${this.ctx.chatSpaceId}`),
192
+ kind: hitlKind(e.hitl_type, e.args),
161
193
  };
162
194
  break;
163
195
  }
164
196
  case 'final_answer':
165
197
  answer = String(event.content ?? answer);
166
198
  break;
199
+ case 'turn_continuing':
200
+ continuing = true;
201
+ break;
167
202
  case 'error': {
168
203
  const e = event;
169
204
  return { answer, cancelled: Boolean(e.cancelled), error: String(e.content ?? 'Unknown error') };
@@ -175,27 +210,107 @@ export class TurnRunner {
175
210
  }
176
211
  }
177
212
  if (pendingHitl) {
178
- return { answer: await this.settleHitl(pendingHitl), cancelled: false };
213
+ const settled = await this.settleHitl(pendingHitl, answer);
214
+ if (isFollowSignal(settled))
215
+ return this.follow(settled.answer);
216
+ return settled;
217
+ }
218
+ if (continuing) {
219
+ return this.follow(answer);
179
220
  }
180
221
  return { answer, cancelled: false };
181
222
  }
182
- async settleHitl(first) {
223
+ async follow(partial) {
224
+ this.following = true;
225
+ let lastSlice = -1;
226
+ this.cb.onStage('Still working — continuing on the server…');
227
+ try {
228
+ while (!this.stopped) {
229
+ await new Promise((r) => setTimeout(r, FOLLOW_POLL_MS));
230
+ let status;
231
+ try {
232
+ status = await getTurnStatus(this.ctx.workspaceId, this.ctx.chatSpaceId);
233
+ }
234
+ catch {
235
+ continue;
236
+ }
237
+ if (typeof status.slice === 'number' && status.slice !== lastSlice) {
238
+ lastSlice = status.slice;
239
+ this.cb.onStage(`Still working — part ${status.slice + 1}`);
240
+ }
241
+ if (status.status === 'done') {
242
+ return { answer: String(status.content ?? partial), cancelled: false };
243
+ }
244
+ if (status.status === 'error') {
245
+ return { answer: partial, cancelled: false, error: String(status.error ?? 'The turn failed.') };
246
+ }
247
+ if (status.status === 'cancelled') {
248
+ return { answer: partial, cancelled: true };
249
+ }
250
+ if (status.status === 'paused') {
251
+ if (String(status.hitl_type ?? '') === 'time_slice') {
252
+ this.cb.onStage('Still working — continuing on the server…');
253
+ continue;
254
+ }
255
+ const settled = await this.settleHitl({
256
+ message: String(status.hitl_prompt ?? 'Approval required.'),
257
+ tool: status.hitl_tool ? String(status.hitl_tool) : undefined,
258
+ options: extractOptions({ ui_schema: status.hitl_ui_schema, args: status.hitl_args }),
259
+ multi: Boolean(status.hitl_ui_schema?.multi),
260
+ threadId: `${this.ctx.workspaceId}_${this.ctx.chatSpaceId}`,
261
+ kind: hitlKind(status.hitl_type, status.hitl_args),
262
+ }, partial);
263
+ if (isFollowSignal(settled)) {
264
+ partial = settled.answer;
265
+ this.cb.onStage('Still working — continuing on the server…');
266
+ continue;
267
+ }
268
+ return settled;
269
+ }
270
+ if (status.status === 'idle') {
271
+ return { answer: partial, cancelled: false, error: 'The turn ended without a result.' };
272
+ }
273
+ }
274
+ return { answer: partial, cancelled: true };
275
+ }
276
+ finally {
277
+ this.following = false;
278
+ }
279
+ }
280
+ async settleHitl(first, priorAnswer = '') {
183
281
  let current = first;
184
- let answer = '';
282
+ let answer = priorAnswer;
185
283
  for (let round = 0; current && round < 25; round += 1) {
186
284
  const decision = await this.enqueuePrompt(() => this.cb.requestDecision(current));
187
- const body = (await resumeTurn({
188
- workspaceId: this.ctx.workspaceId,
189
- chatSpaceId: this.ctx.chatSpaceId,
190
- threadId: current.threadId,
191
- sessionId: this.ctx.sessionId,
192
- sessionKey: this.key.toBase64(),
193
- approved: decision.approved,
194
- feedback: decision.feedback,
195
- modifiedArgs: decision.modifiedArgs,
196
- }));
285
+ if (decision.codeMode)
286
+ this.setCodeMode(decision.codeMode);
287
+ let body;
288
+ try {
289
+ body = await resumeTurn({
290
+ workspaceId: this.ctx.workspaceId,
291
+ chatSpaceId: this.ctx.chatSpaceId,
292
+ threadId: current.threadId,
293
+ sessionId: this.ctx.sessionId,
294
+ sessionKey: this.key.toBase64(),
295
+ approved: decision.approved,
296
+ feedback: decision.feedback,
297
+ modifiedArgs: decision.modifiedArgs,
298
+ codeMode: decision.codeMode ?? null,
299
+ codeRate: decision.codeMode ? permissionModeToRate(this.ctx.mode) : null,
300
+ });
301
+ }
302
+ catch (err) {
303
+ const detail = String(err.message ?? '');
304
+ if (/still running|background/i.test(detail)) {
305
+ return this.continueAfterHitl(answer);
306
+ }
307
+ throw err;
308
+ }
197
309
  answer = String(body.response ?? answer);
198
310
  const meta = body.context_metadata ?? {};
311
+ if (meta.continuing) {
312
+ return this.continueAfterHitl(answer);
313
+ }
199
314
  current = meta.paused
200
315
  ? {
201
316
  message: String(meta.hitl_prompt ?? body.response ?? 'Approval required.'),
@@ -203,10 +318,43 @@ export class TurnRunner {
203
318
  options: extractOptions({ ui_schema: meta.hitl_ui_schema, args: meta.hitl_args }),
204
319
  multi: Boolean(meta.hitl_ui_schema?.multi),
205
320
  threadId: String(meta.thread_id ?? current.threadId),
321
+ kind: hitlKind(meta.hitl_type, meta.hitl_args),
206
322
  }
207
323
  : null;
208
324
  }
209
- return answer;
325
+ try {
326
+ const status = await getTurnStatus(this.ctx.workspaceId, this.ctx.chatSpaceId);
327
+ if (status.status === 'running' || status.continuing) {
328
+ return this.continueAfterHitl(answer);
329
+ }
330
+ if (status.status === 'paused' && String(status.hitl_type ?? '') !== 'time_slice') {
331
+ return this.settleHitl({
332
+ message: String(status.hitl_prompt ?? 'Approval required.'),
333
+ tool: status.hitl_tool ? String(status.hitl_tool) : undefined,
334
+ options: extractOptions({ ui_schema: status.hitl_ui_schema, args: status.hitl_args }),
335
+ multi: Boolean(status.hitl_ui_schema?.multi),
336
+ threadId: `${this.ctx.workspaceId}_${this.ctx.chatSpaceId}`,
337
+ kind: hitlKind(status.hitl_type, status.hitl_args),
338
+ }, answer);
339
+ }
340
+ if (status.status === 'error') {
341
+ return { answer, cancelled: false, error: String(status.error ?? 'The turn failed.') };
342
+ }
343
+ if (status.status === 'cancelled') {
344
+ return { answer, cancelled: true };
345
+ }
346
+ if (status.status === 'done' && status.content) {
347
+ return { answer: String(status.content), cancelled: false };
348
+ }
349
+ }
350
+ catch {
351
+ }
352
+ return { answer, cancelled: false };
353
+ }
354
+ async continueAfterHitl(answer) {
355
+ if (this.following)
356
+ return { follow: true, answer };
357
+ return this.follow(answer);
210
358
  }
211
359
  dispatch(request) {
212
360
  if (this.stopped || this.seen.has(request.request_id))
@@ -218,6 +366,10 @@ export class TurnRunner {
218
366
  this.seen.add(id);
219
367
  }
220
368
  this.seen.add(request.request_id);
369
+ if (this.following && request.op !== 'ask') {
370
+ const args = (request.args ?? {});
371
+ this.cb.onTool(String(request.op), String(args.command ?? args.path ?? args.file_path ?? ''));
372
+ }
221
373
  const task = (async () => {
222
374
  let outcome;
223
375
  try {
@@ -267,6 +419,10 @@ export class TurnRunner {
267
419
  catch (err) {
268
420
  outcome = { ok: false, error: err.message };
269
421
  }
422
+ if (this.following && outcome.ok) {
423
+ for (const path of writtenPaths(request))
424
+ this.cb.onFileTouched(path, 'written');
425
+ }
270
426
  try {
271
427
  await postFsResult(this.ctx.sessionId, request.request_id, outcome, this.key);
272
428
  }
@@ -334,14 +490,34 @@ export class TurnRunner {
334
490
  }
335
491
  }
336
492
  async shutdown() {
493
+ const wasActive = this.turnActive;
337
494
  this.stopped = true;
338
495
  this.stopPolling();
496
+ if (wasActive)
497
+ await cancelTurn(this.ctx.workspaceId, this.ctx.chatSpaceId);
339
498
  await closeSession(this.ctx.sessionId);
340
499
  }
341
500
  }
342
- function extractOptions(event) {
501
+ export function hitlKind(hitlType, args) {
502
+ const field = args && typeof args === 'object' ? String(args.field ?? '') : '';
503
+ return String(hitlType ?? '') === 'code_clarification' && field === 'mode'
504
+ ? 'code_mode'
505
+ : 'other';
506
+ }
507
+ export function extractOptions(event) {
508
+ const schemaEnum = (() => {
509
+ const props = event?.ui_schema?.properties;
510
+ if (!props || typeof props !== 'object')
511
+ return undefined;
512
+ for (const value of Object.values(props)) {
513
+ if (Array.isArray(value?.enum) && value.enum.length)
514
+ return value.enum;
515
+ }
516
+ return undefined;
517
+ })();
343
518
  const raw = event?.ui_schema?.options ??
344
519
  event?.ui_schema?.choices ??
520
+ schemaEnum ??
345
521
  event?.args?.options ??
346
522
  event?.options;
347
523
  if (!Array.isArray(raw) || raw.length === 0)
@@ -104,6 +104,9 @@ 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' };
109
+ }
107
110
  function runWithPty(pty, command, options) {
108
111
  const { file, args } = shellInvocation(command);
109
112
  const tail = new Tail(MAX_OUTPUT_CHARS);
@@ -112,7 +115,7 @@ function runWithPty(pty, command, options) {
112
115
  cols: options.cols ?? 120,
113
116
  rows: options.rows ?? 30,
114
117
  cwd: options.cwd,
115
- env: process.env,
118
+ env: agentEnv(),
116
119
  });
117
120
  let settled = false;
118
121
  let timedOut = false;
@@ -189,7 +192,7 @@ function runWithPipes(command, options) {
189
192
  const tail = new Tail(MAX_OUTPUT_CHARS);
190
193
  const child = spawn(file, args, {
191
194
  cwd: options.cwd,
192
- env: process.env,
195
+ env: agentEnv(),
193
196
  stdio: ['pipe', 'pipe', 'pipe'],
194
197
  });
195
198
  let settled = false;
@@ -11,6 +11,7 @@
11
11
  import { InsufficientCredits } from './api/workspace.js';
12
12
  import { siteUrl } from './config.js';
13
13
  import { TurnRunner } from './protocol/runner.js';
14
+ import { permissionModeToRate } from './protocol/permissions.js';
14
15
  import { creditShortfall, startSession } from './session.js';
15
16
  import { HeadlessRenderer, installTeardown } from './ui/headless.js';
16
17
  export async function runHeadless(options) {
@@ -84,7 +85,9 @@ export async function runHeadless(options) {
84
85
  },
85
86
  requestDecision: async (request) => {
86
87
  out.endAnswer();
87
- out.line('notice', `Approval needed but no terminal to ask on: ${request.message}`);
88
+ out.line('notice', request.kind === 'code_mode'
89
+ ? 'No Mode selected. Pass -m frontend|backend|fullstack|simple|security.'
90
+ : `Approval needed but no terminal to ask on: ${request.message}`);
88
91
  return { approved: false, feedback: 'No interactive terminal available.' };
89
92
  },
90
93
  askQuestion: async (question) => {
@@ -102,6 +105,11 @@ export async function runHeadless(options) {
102
105
  answer = result.answer ?? '';
103
106
  if (result.error) {
104
107
  out.line('error', result.error);
108
+ if (/credit|insufficient/i.test(result.error)) {
109
+ out.line('notice', `Top up at ${siteUrl()}/credits`);
110
+ emit(false, 2, result.error);
111
+ return 2;
112
+ }
105
113
  emit(false, 1, result.error);
106
114
  return 1;
107
115
  }
@@ -111,7 +119,7 @@ export async function runHeadless(options) {
111
119
  if (out.refusalCount > 0) {
112
120
  const advice = options.mode === 'plan'
113
121
  ? 'plan mode makes no changes — re-run without --plan'
114
- : `re-run with -m acceptEdits, or --bypass in CI (mode was ${options.mode})`;
122
+ : `re-run with --rate bypass (Rate was ${permissionModeToRate(options.mode)})`;
115
123
  const message = `${out.refusalCount} operation(s) were skipped because there is no terminal ` +
116
124
  `to approve them: ${advice}.`;
117
125
  out.line('error', message);
package/dist/session.js CHANGED
@@ -8,12 +8,25 @@
8
8
  * accompanying it; it is not open source and is not licensed for
9
9
  * redistribution or derivative works.
10
10
  */
11
+ import { readFileSync } from 'node:fs';
11
12
  import { stat } from 'node:fs/promises';
12
- import { basename, join } from 'node:path';
13
- import { bootstrap, fetchCredits } from './api/workspace.js';
13
+ import { basename, dirname, join } from 'node:path';
14
+ import { fileURLToPath } from 'node:url';
15
+ import { bootstrap, fetchCredits, OnboardingRequired } from './api/workspace.js';
16
+ import { openBrowser } from './auth/pkce.js';
14
17
  import { isSignedIn, login } from './auth/session.js';
15
18
  import { McpManager } from './mcp/manager.js';
16
19
  import { buildSnapshot } from './protocol/snapshot.js';
20
+ export function packageVersion() {
21
+ try {
22
+ const here = dirname(fileURLToPath(import.meta.url));
23
+ const pkg = JSON.parse(readFileSync(join(here, '..', 'package.json'), 'utf8'));
24
+ return String(pkg.version ?? '0.0.0');
25
+ }
26
+ catch {
27
+ return '0.0.0';
28
+ }
29
+ }
17
30
  async function localMemoryPresent(cwd) {
18
31
  try {
19
32
  const info = await stat(join(cwd, '.rivine', 'local_memory.md'));
@@ -23,6 +36,30 @@ async function localMemoryPresent(cwd) {
23
36
  return false;
24
37
  }
25
38
  }
39
+ const ONBOARDING_POLL_MS = 5_000;
40
+ const ONBOARDING_WAIT_MS = 20 * 60_000;
41
+ async function bootstrapAfterOnboarding(cwd, project, options) {
42
+ let waitingSince = 0;
43
+ for (;;) {
44
+ try {
45
+ return await bootstrap(cwd, project);
46
+ }
47
+ catch (err) {
48
+ if (!(err instanceof OnboardingRequired))
49
+ throw err;
50
+ if (!waitingSince) {
51
+ waitingSince = Date.now();
52
+ options.onNotice?.(`${err.message} Opening ${err.url} — choose your cloudhandle there, then come back. Rivine Code continues by itself.`);
53
+ options.onLoginUrl?.(err.url);
54
+ openBrowser(err.url);
55
+ }
56
+ else if (Date.now() - waitingSince > ONBOARDING_WAIT_MS) {
57
+ throw new Error(`Your Aselius account is not set up yet. Finish it at ${err.url}, then run \`rivine\` again.`);
58
+ }
59
+ await new Promise((r) => setTimeout(r, ONBOARDING_POLL_MS));
60
+ }
61
+ }
62
+ }
26
63
  export async function startSession(options) {
27
64
  const { cwd } = options;
28
65
  if (!(await isSignedIn())) {
@@ -33,7 +70,10 @@ export async function startSession(options) {
33
70
  });
34
71
  }
35
72
  const project = basename(cwd) || 'project';
36
- const result = await bootstrap(cwd, project);
73
+ const result = await bootstrapAfterOnboarding(cwd, project, options);
74
+ for (const notice of continuationNotices(result, packageVersion())) {
75
+ options.onNotice?.(notice);
76
+ }
37
77
  const mcp = new McpManager();
38
78
  const mcpReady = mcp.connectAll(cwd).catch(() => []);
39
79
  return {
@@ -55,3 +95,29 @@ export function creditShortfall(balance, minimums) {
55
95
  return null;
56
96
  return balance < floor ? floor : null;
57
97
  }
98
+ export function compareSemver(a, b) {
99
+ const parts = (v) => String(v || '0')
100
+ .replace(/^v/i, '')
101
+ .split(/[.+-]/)
102
+ .map((p) => parseInt(p, 10) || 0);
103
+ const aa = parts(a);
104
+ const bb = parts(b);
105
+ const n = Math.max(aa.length, bb.length);
106
+ for (let i = 0; i < n; i += 1) {
107
+ const d = (aa[i] ?? 0) - (bb[i] ?? 0);
108
+ if (d)
109
+ return d < 0 ? -1 : 1;
110
+ }
111
+ return 0;
112
+ }
113
+ export function continuationNotices(bootstrap, localVersion) {
114
+ const out = [];
115
+ const min = bootstrap.min_cli_version;
116
+ if (min && compareSemver(localVersion, min) < 0) {
117
+ out.push(`This CLI is ${localVersion}; long turns need >= ${min}. Upgrade: npm install -g @beunec/rivine@latest`);
118
+ }
119
+ if (bootstrap.continuation_enabled === false) {
120
+ out.push('This Workspace has turn continuation off — long Rivine Code builds may stop at the server time limit.');
121
+ }
122
+ return out;
123
+ }