@beunec/rivine 0.3.0 → 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.
@@ -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 { closeSession, 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';
@@ -18,6 +18,24 @@ import { selectRelevantTools } from '../mcp/relevance.js';
18
18
  import { readTurnStream } from './stream.js';
19
19
  import { displayStage } from '../ui/render.js';
20
20
  const PENDING_POLL_MS = 1200;
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
+ }
27
+ export function writtenPaths(request) {
28
+ const args = (request.args ?? {});
29
+ let paths = [];
30
+ if (request.op === 'write' || request.op === 'edit')
31
+ paths = [String(args.file_path ?? args.path ?? '')];
32
+ else if (request.op === 'upload')
33
+ paths = (args.files ?? []).map((f) => String(f?.path ?? ''));
34
+ return paths.filter((p) => p && !/(^|\/)\.rivine\//.test(p));
35
+ }
36
+ function isFollowSignal(value) {
37
+ return value.follow === true;
38
+ }
21
39
  export class TurnRunner {
22
40
  ctx;
23
41
  cb;
@@ -27,6 +45,8 @@ export class TurnRunner {
27
45
  approvalChain = Promise.resolve();
28
46
  inFlight = new Set();
29
47
  stopped = false;
48
+ turnActive = false;
49
+ following = false;
30
50
  pollTimer = null;
31
51
  constructor(ctx, cb, mcp) {
32
52
  this.ctx = ctx;
@@ -82,6 +102,7 @@ export class TurnRunner {
82
102
  };
83
103
  }
84
104
  this.stopped = false;
105
+ this.turnActive = true;
85
106
  this.startPolling();
86
107
  const selection = selectRelevantTools(message, this.ctx.mcpTools ?? []);
87
108
  if (selection.servers.length) {
@@ -89,7 +110,7 @@ export class TurnRunner {
89
110
  (selection.skipped.length ? ` (not needed: ${selection.skipped.join(', ')})` : ''));
90
111
  }
91
112
  try {
92
- const response = await openTurn({
113
+ const request = {
93
114
  workspaceId: this.ctx.workspaceId,
94
115
  chatSpaceId: this.ctx.chatSpaceId,
95
116
  sessionId: this.ctx.sessionId,
@@ -104,115 +125,246 @@ export class TurnRunner {
104
125
  input_schema,
105
126
  })),
106
127
  sessionKey: this.key.toBase64(),
107
- });
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
+ }
108
153
  return await this.consume(response);
109
154
  }
110
155
  finally {
156
+ this.turnActive = false;
157
+ this.following = false;
111
158
  this.stopPolling();
112
159
  await Promise.allSettled([...this.inFlight]);
113
160
  }
114
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
+ }
115
179
  async consume(response) {
116
180
  let answer = '';
117
181
  let pendingHitl = null;
118
- for await (const event of readTurnStream(response)) {
119
- switch (event.type) {
120
- case 'token':
121
- answer += event.content ?? '';
122
- this.cb.onToken(event.content ?? '');
123
- break;
124
- case 'stage': {
125
- const label = displayStage(String(event.label ?? ''));
126
- if (label)
127
- this.cb.onStage(label);
128
- break;
182
+ let continuing = false;
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 ?? '');
189
+ break;
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:
256
+ break;
129
257
  }
130
- case 'tool_call_start': {
131
- const e = event;
132
- const detail = e.args?.command ?? e.args?.filename ?? '';
133
- this.cb.onTool(String(e.name ?? 'tool'), String(detail));
134
- break;
258
+ }
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
+ }
266
+ if (pendingHitl) {
267
+ const settled = await this.settleHitl(pendingHitl, answer);
268
+ if (isFollowSignal(settled))
269
+ return this.follow(settled.answer);
270
+ return settled;
271
+ }
272
+ if (continuing) {
273
+ return this.follow(answer);
274
+ }
275
+ return { answer, cancelled: false };
276
+ }
277
+ async follow(partial) {
278
+ this.following = true;
279
+ let lastSlice = -1;
280
+ this.cb.onStage('Still working — continuing on the server…');
281
+ try {
282
+ while (!this.stopped) {
283
+ await new Promise((r) => setTimeout(r, FOLLOW_POLL_MS));
284
+ let status;
285
+ try {
286
+ status = await getTurnStatus(this.ctx.workspaceId, this.ctx.chatSpaceId);
135
287
  }
136
- case 'file_diff': {
137
- const e = event;
138
- const path = String(e.path ?? '').trim();
139
- if (!path)
140
- break;
141
- this.cb.onFileDiff?.({
142
- path: path.replace(/^\/workspace\/?/, ''),
143
- stat: String(e.stat ?? ''),
144
- patch: String(e.patch ?? ''),
145
- added: Number(e.added ?? 0),
146
- removed: Number(e.removed ?? 0),
147
- isNew: Boolean(e.is_new),
148
- isDelete: Boolean(e.is_delete),
149
- binary: Boolean(e.binary),
150
- });
151
- break;
288
+ catch {
289
+ continue;
152
290
  }
153
- case 'code_file': {
154
- const e = event;
155
- const path = String(e.path ?? '').trim();
156
- const rel = path.replace(/^\/workspace\/?/, '');
157
- if (!rel || rel === '.' || rel.endsWith('/'))
158
- break;
159
- this.cb.onFileTouched(path, e.status);
160
- break;
291
+ if (typeof status.slice === 'number' && status.slice !== lastSlice) {
292
+ lastSlice = status.slice;
293
+ this.cb.onStage(`Still working — part ${status.slice + 1}`);
161
294
  }
162
- case 'fs_request':
163
- this.dispatch(event);
164
- break;
165
- case 'approval_request': {
166
- const e = event;
167
- pendingHitl = {
168
- message: String(e.message ?? 'Approval required.'),
169
- tool: e.tool ? String(e.tool) : undefined,
170
- options: extractOptions(e),
171
- multi: Boolean(e.ui_schema?.multi ?? e.args?.multi),
172
- threadId: String(e.thread_id ?? `${this.ctx.workspaceId}_${this.ctx.chatSpaceId}`),
173
- kind: hitlKind(e.hitl_type, e.args),
174
- };
175
- break;
295
+ if (status.status === 'done') {
296
+ return { answer: String(status.content ?? partial), cancelled: false };
176
297
  }
177
- case 'final_answer':
178
- answer = String(event.content ?? answer);
179
- break;
180
- case 'error': {
181
- const e = event;
182
- return { answer, cancelled: Boolean(e.cancelled), error: String(e.content ?? 'Unknown error') };
298
+ if (status.status === 'error') {
299
+ return { answer: partial, cancelled: false, error: String(status.error ?? 'The turn failed.') };
300
+ }
301
+ if (status.status === 'cancelled') {
302
+ return { answer: partial, cancelled: true };
303
+ }
304
+ if (status.status === 'paused') {
305
+ if (String(status.hitl_type ?? '') === 'time_slice') {
306
+ this.cb.onStage('Still working — continuing on the server…');
307
+ continue;
308
+ }
309
+ const settled = await this.settleHitl({
310
+ message: String(status.hitl_prompt ?? 'Approval required.'),
311
+ tool: status.hitl_tool ? String(status.hitl_tool) : undefined,
312
+ options: extractOptions({ ui_schema: status.hitl_ui_schema, args: status.hitl_args }),
313
+ multi: Boolean(status.hitl_ui_schema?.multi),
314
+ threadId: `${this.ctx.workspaceId}_${this.ctx.chatSpaceId}`,
315
+ kind: hitlKind(status.hitl_type, status.hitl_args),
316
+ }, partial);
317
+ if (isFollowSignal(settled)) {
318
+ partial = settled.answer;
319
+ this.cb.onStage('Still working — continuing on the server…');
320
+ continue;
321
+ }
322
+ return settled;
323
+ }
324
+ if (status.status === 'idle') {
325
+ return { answer: partial, cancelled: false, error: 'The turn ended without a result.' };
183
326
  }
184
- case 'done':
185
- break;
186
- default:
187
- break;
188
327
  }
328
+ return { answer: partial, cancelled: true };
189
329
  }
190
- if (pendingHitl) {
191
- return { answer: await this.settleHitl(pendingHitl), cancelled: false };
330
+ finally {
331
+ this.following = false;
192
332
  }
193
- return { answer, cancelled: false };
194
333
  }
195
- async settleHitl(first) {
334
+ async settleHitl(first, priorAnswer = '') {
196
335
  let current = first;
197
- let answer = '';
336
+ let answer = priorAnswer;
198
337
  for (let round = 0; current && round < 25; round += 1) {
199
338
  const decision = await this.enqueuePrompt(() => this.cb.requestDecision(current));
200
339
  if (decision.codeMode)
201
340
  this.setCodeMode(decision.codeMode);
202
- const body = (await resumeTurn({
203
- workspaceId: this.ctx.workspaceId,
204
- chatSpaceId: this.ctx.chatSpaceId,
205
- threadId: current.threadId,
206
- sessionId: this.ctx.sessionId,
207
- sessionKey: this.key.toBase64(),
208
- approved: decision.approved,
209
- feedback: decision.feedback,
210
- modifiedArgs: decision.modifiedArgs,
211
- codeMode: decision.codeMode ?? null,
212
- codeRate: decision.codeMode ? permissionModeToRate(this.ctx.mode) : null,
213
- }));
341
+ let body;
342
+ try {
343
+ body = await resumeTurn({
344
+ workspaceId: this.ctx.workspaceId,
345
+ chatSpaceId: this.ctx.chatSpaceId,
346
+ threadId: current.threadId,
347
+ sessionId: this.ctx.sessionId,
348
+ sessionKey: this.key.toBase64(),
349
+ approved: decision.approved,
350
+ feedback: decision.feedback,
351
+ modifiedArgs: decision.modifiedArgs,
352
+ codeMode: decision.codeMode ?? null,
353
+ codeRate: decision.codeMode ? permissionModeToRate(this.ctx.mode) : null,
354
+ });
355
+ }
356
+ catch (err) {
357
+ const detail = String(err.message ?? '');
358
+ if (/still running|background/i.test(detail)) {
359
+ return this.continueAfterHitl(answer);
360
+ }
361
+ throw err;
362
+ }
214
363
  answer = String(body.response ?? answer);
215
364
  const meta = body.context_metadata ?? {};
365
+ if (meta.continuing) {
366
+ return this.continueAfterHitl(answer);
367
+ }
216
368
  current = meta.paused
217
369
  ? {
218
370
  message: String(meta.hitl_prompt ?? body.response ?? 'Approval required.'),
@@ -224,7 +376,39 @@ export class TurnRunner {
224
376
  }
225
377
  : null;
226
378
  }
227
- return answer;
379
+ try {
380
+ const status = await getTurnStatus(this.ctx.workspaceId, this.ctx.chatSpaceId);
381
+ if (status.status === 'running' || status.continuing) {
382
+ return this.continueAfterHitl(answer);
383
+ }
384
+ if (status.status === 'paused' && String(status.hitl_type ?? '') !== 'time_slice') {
385
+ return this.settleHitl({
386
+ message: String(status.hitl_prompt ?? 'Approval required.'),
387
+ tool: status.hitl_tool ? String(status.hitl_tool) : undefined,
388
+ options: extractOptions({ ui_schema: status.hitl_ui_schema, args: status.hitl_args }),
389
+ multi: Boolean(status.hitl_ui_schema?.multi),
390
+ threadId: `${this.ctx.workspaceId}_${this.ctx.chatSpaceId}`,
391
+ kind: hitlKind(status.hitl_type, status.hitl_args),
392
+ }, answer);
393
+ }
394
+ if (status.status === 'error') {
395
+ return { answer, cancelled: false, error: String(status.error ?? 'The turn failed.') };
396
+ }
397
+ if (status.status === 'cancelled') {
398
+ return { answer, cancelled: true };
399
+ }
400
+ if (status.status === 'done' && status.content) {
401
+ return { answer: String(status.content), cancelled: false };
402
+ }
403
+ }
404
+ catch {
405
+ }
406
+ return { answer, cancelled: false };
407
+ }
408
+ async continueAfterHitl(answer) {
409
+ if (this.following)
410
+ return { follow: true, answer };
411
+ return this.follow(answer);
228
412
  }
229
413
  dispatch(request) {
230
414
  if (this.stopped || this.seen.has(request.request_id))
@@ -236,6 +420,10 @@ export class TurnRunner {
236
420
  this.seen.add(id);
237
421
  }
238
422
  this.seen.add(request.request_id);
423
+ if (this.following && request.op !== 'ask') {
424
+ const args = (request.args ?? {});
425
+ this.cb.onTool(String(request.op), String(args.command ?? args.path ?? args.file_path ?? ''));
426
+ }
239
427
  const task = (async () => {
240
428
  let outcome;
241
429
  try {
@@ -285,6 +473,10 @@ export class TurnRunner {
285
473
  catch (err) {
286
474
  outcome = { ok: false, error: err.message };
287
475
  }
476
+ if (this.following && outcome.ok) {
477
+ for (const path of writtenPaths(request))
478
+ this.cb.onFileTouched(path, 'written');
479
+ }
288
480
  try {
289
481
  await postFsResult(this.ctx.sessionId, request.request_id, outcome, this.key);
290
482
  }
@@ -352,8 +544,11 @@ export class TurnRunner {
352
544
  }
353
545
  }
354
546
  async shutdown() {
547
+ const wasActive = this.turnActive;
355
548
  this.stopped = true;
356
549
  this.stopPolling();
550
+ if (wasActive)
551
+ await cancelTurn(this.ctx.workspaceId, this.ctx.chatSpaceId);
357
552
  await closeSession(this.ctx.sessionId);
358
553
  }
359
554
  }
@@ -104,6 +104,9 @@ function shellInvocation(command) {
104
104
  }
105
105
  return { file: process.env.SHELL || '/bin/sh', args: ['-c', command] };
106
106
  }
107
+ export function agentEnv() {
108
+ return { ...process.env, CI: process.env.CI || '1', 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;
@@ -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),
@@ -105,6 +106,11 @@ export async function runHeadless(options) {
105
106
  answer = result.answer ?? '';
106
107
  if (result.error) {
107
108
  out.line('error', result.error);
109
+ if (/credit|insufficient/i.test(result.error)) {
110
+ out.line('notice', `Top up at ${siteUrl()}/credits`);
111
+ emit(false, 2, result.error);
112
+ return 2;
113
+ }
108
114
  emit(false, 1, result.error);
109
115
  return 1;
110
116
  }