@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.
- package/README.md +69 -68
- package/dist/api/workspace.js +44 -4
- 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 +280 -85
- package/dist/protocol/shell.js +5 -2
- package/dist/runHeadless.js +6 -0
- package/dist/session.js +69 -3
- package/dist/ui/App.js +24 -4
- package/dist/ui/headless.js +5 -1
- package/dist/ui/slashCommands.js +5 -0
- package/package.json +1 -1
|
@@ -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 { 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
|
|
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
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
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
|
-
|
|
137
|
-
|
|
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
|
-
|
|
154
|
-
|
|
155
|
-
|
|
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
|
-
|
|
163
|
-
|
|
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
|
-
|
|
178
|
-
answer
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
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
|
-
|
|
191
|
-
|
|
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
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
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
|
-
|
|
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
|
}
|
package/dist/protocol/shell.js
CHANGED
|
@@ -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:
|
|
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:
|
|
195
|
+
env: agentEnv(),
|
|
193
196
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
194
197
|
});
|
|
195
198
|
let settled = false;
|
package/dist/runHeadless.js
CHANGED
|
@@ -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
|
}
|