termux-dev 1.8.1 → 1.8.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 +3 -0
- package/assets/banner.svg +1 -1
- package/assets/preview.svg +1 -1
- package/dist/cli/background-ui.js +116 -0
- package/dist/cli/headless.js +3 -1
- package/dist/cli/index.js +135 -19
- package/dist/cli/prompt.js +39 -1
- package/dist/cli/ui.js +162 -25
- package/dist/cli/updater.js +10 -8
- package/dist/core/background.js +376 -0
- package/dist/core/config-merge.js +67 -0
- package/dist/core/loop.js +18 -1
- package/dist/core/types.js +1 -1
- package/dist/permissions/guard.js +3 -1
- package/dist/prompts/builder.js +2 -1
- package/dist/tools/background.js +118 -0
- package/dist/tools/bash.js +23 -1
- package/dist/tools/index.js +2 -1
- package/package.json +1 -1
|
@@ -0,0 +1,376 @@
|
|
|
1
|
+
import { spawn } from 'child_process';
|
|
2
|
+
import { EventEmitter } from 'events';
|
|
3
|
+
import { StringDecoder } from 'string_decoder';
|
|
4
|
+
export const MAX_BACKGROUND_TASKS = 8;
|
|
5
|
+
/** What is kept of the output of one task; older text is dropped. */
|
|
6
|
+
export const MAX_BUFFER = 200_000;
|
|
7
|
+
/** The most a single read gives back to the model. */
|
|
8
|
+
export const MAX_READ_CHARS = 8000;
|
|
9
|
+
const MAX_NOTES_PER_TASK = 12;
|
|
10
|
+
const NOTE_TAIL_CHARS = 1500;
|
|
11
|
+
const MATCH_DEBOUNCE_MS = 400;
|
|
12
|
+
/** Strips colour codes and carriage-return redraws so the model reads text, not terminal noise. */
|
|
13
|
+
export function cleanOutput(text) {
|
|
14
|
+
return text
|
|
15
|
+
.replace(/\x1b\[[0-9;?]*[ -/]*[@-~]/g, '')
|
|
16
|
+
.replace(/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g, '')
|
|
17
|
+
.replace(/[^\n]*\r(?!\n)/g, '')
|
|
18
|
+
.replace(/\r/g, '');
|
|
19
|
+
}
|
|
20
|
+
function compileRegex(source) {
|
|
21
|
+
if (!source)
|
|
22
|
+
return undefined;
|
|
23
|
+
try {
|
|
24
|
+
return new RegExp(source, 'i');
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
throw new Error(`Invalid regular expression: ${source}`);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
export function formatDuration(ms) {
|
|
31
|
+
const s = Math.max(0, Math.round(ms / 1000));
|
|
32
|
+
if (s < 60)
|
|
33
|
+
return `${s}s`;
|
|
34
|
+
const m = Math.floor(s / 60);
|
|
35
|
+
if (m < 60)
|
|
36
|
+
return `${m}m ${String(s % 60).padStart(2, '0')}s`;
|
|
37
|
+
return `${Math.floor(m / 60)}h ${String(m % 60).padStart(2, '0')}m`;
|
|
38
|
+
}
|
|
39
|
+
export class BackgroundManager extends EventEmitter {
|
|
40
|
+
tasks = new Map();
|
|
41
|
+
counter = 0;
|
|
42
|
+
notes = [];
|
|
43
|
+
// ───────────────────────── starting and stopping ─────────────────────────
|
|
44
|
+
start(command, options = {}) {
|
|
45
|
+
if (!command || !command.trim())
|
|
46
|
+
throw new Error('command is required');
|
|
47
|
+
if (this.running().length >= MAX_BACKGROUND_TASKS) {
|
|
48
|
+
throw new Error(`Too many background tasks running (${MAX_BACKGROUND_TASKS}). Stop one first (action "stop").`);
|
|
49
|
+
}
|
|
50
|
+
const watch = compileRegex(options.notifyOn);
|
|
51
|
+
this.installExitHook();
|
|
52
|
+
const isWin = process.platform === 'win32';
|
|
53
|
+
const cwd = options.cwd || process.cwd();
|
|
54
|
+
const proc = spawn(command, { shell: true, cwd, detached: !isWin, stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true });
|
|
55
|
+
const id = `bg${++this.counter}`;
|
|
56
|
+
const info = {
|
|
57
|
+
id,
|
|
58
|
+
label: (options.label || command).replace(/\s+/g, ' ').trim().slice(0, 60),
|
|
59
|
+
command,
|
|
60
|
+
cwd,
|
|
61
|
+
pid: proc.pid,
|
|
62
|
+
status: 'running',
|
|
63
|
+
code: null,
|
|
64
|
+
startedAt: Date.now(),
|
|
65
|
+
outputChars: 0,
|
|
66
|
+
watching: options.notifyOn || undefined,
|
|
67
|
+
};
|
|
68
|
+
let finish;
|
|
69
|
+
const done = new Promise((resolve) => { finish = resolve; });
|
|
70
|
+
const task = {
|
|
71
|
+
info, proc, buffer: '', trimmed: 0, readCursor: 0, scanFrom: 0, pendingLine: '', watch,
|
|
72
|
+
notes: 0, matchedLines: [], stopRequested: false, timedOut: false, done,
|
|
73
|
+
};
|
|
74
|
+
this.tasks.set(id, task);
|
|
75
|
+
const decoders = { out: new StringDecoder('utf8'), err: new StringDecoder('utf8') };
|
|
76
|
+
proc.stdout.on('data', (d) => this.append(task, decoders.out.write(d)));
|
|
77
|
+
proc.stderr.on('data', (d) => this.append(task, decoders.err.write(d)));
|
|
78
|
+
let ended = false;
|
|
79
|
+
const end = (status, code) => {
|
|
80
|
+
if (ended)
|
|
81
|
+
return;
|
|
82
|
+
ended = true;
|
|
83
|
+
this.append(task, decoders.out.end() + decoders.err.end());
|
|
84
|
+
this.flushPendingLine(task);
|
|
85
|
+
clearTimeout(task.killTimer);
|
|
86
|
+
clearTimeout(task.runtimeTimer);
|
|
87
|
+
info.status = task.stopRequested ? 'stopped' : status;
|
|
88
|
+
info.code = code;
|
|
89
|
+
info.endedAt = Date.now();
|
|
90
|
+
this.flushMatches(task);
|
|
91
|
+
// A task the model stopped itself needs no note; anything else does.
|
|
92
|
+
if (!task.stopRequested || task.timedOut)
|
|
93
|
+
this.addNote(task, 'exit', this.exitNote(task));
|
|
94
|
+
this.emit('change', this.snapshot(task));
|
|
95
|
+
finish();
|
|
96
|
+
};
|
|
97
|
+
proc.on('error', () => end('failed', null));
|
|
98
|
+
proc.on('close', (code) => end(code === 0 ? 'exited' : 'failed', code));
|
|
99
|
+
// A child that got away can keep the pipes open for good: do not wait for them for long.
|
|
100
|
+
proc.on('exit', (code) => {
|
|
101
|
+
setTimeout(() => {
|
|
102
|
+
proc.stdout?.destroy();
|
|
103
|
+
proc.stderr?.destroy();
|
|
104
|
+
end(code === 0 ? 'exited' : 'failed', code);
|
|
105
|
+
}, 400).unref();
|
|
106
|
+
});
|
|
107
|
+
if (options.maxRuntimeSeconds && options.maxRuntimeSeconds > 0) {
|
|
108
|
+
task.runtimeTimer = setTimeout(() => { task.timedOut = true; void this.stop(id); }, options.maxRuntimeSeconds * 1000);
|
|
109
|
+
task.runtimeTimer.unref();
|
|
110
|
+
}
|
|
111
|
+
this.emit('change', this.snapshot(task));
|
|
112
|
+
return this.snapshot(task);
|
|
113
|
+
}
|
|
114
|
+
/** Stops a task (SIGTERM, then SIGKILL after a second). Resolves when it is gone. */
|
|
115
|
+
async stop(id) {
|
|
116
|
+
const task = this.must(id);
|
|
117
|
+
if (task.info.status !== 'running')
|
|
118
|
+
return this.snapshot(task);
|
|
119
|
+
task.stopRequested = true;
|
|
120
|
+
this.killTree(task, false);
|
|
121
|
+
task.killTimer = setTimeout(() => this.killTree(task, true), 1000);
|
|
122
|
+
task.killTimer.unref();
|
|
123
|
+
await Promise.race([task.done, new Promise((r) => setTimeout(r, 2500).unref())]);
|
|
124
|
+
return this.snapshot(task);
|
|
125
|
+
}
|
|
126
|
+
async stopAll() {
|
|
127
|
+
const running = this.running();
|
|
128
|
+
await Promise.all(running.map((t) => this.stop(t.id).catch(() => null)));
|
|
129
|
+
return running.length;
|
|
130
|
+
}
|
|
131
|
+
/** Forgets finished tasks (their output is gone with them). */
|
|
132
|
+
clearFinished() {
|
|
133
|
+
let n = 0;
|
|
134
|
+
for (const [id, task] of this.tasks) {
|
|
135
|
+
if (task.info.status !== 'running') {
|
|
136
|
+
this.tasks.delete(id);
|
|
137
|
+
n++;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
if (n)
|
|
141
|
+
this.emit('change');
|
|
142
|
+
return n;
|
|
143
|
+
}
|
|
144
|
+
// ───────────────────────── looking at tasks ─────────────────────────
|
|
145
|
+
list() {
|
|
146
|
+
return [...this.tasks.values()].map((t) => this.snapshot(t));
|
|
147
|
+
}
|
|
148
|
+
running() {
|
|
149
|
+
return this.list().filter((t) => t.status === 'running');
|
|
150
|
+
}
|
|
151
|
+
get(id) {
|
|
152
|
+
const task = this.tasks.get(id);
|
|
153
|
+
return task ? this.snapshot(task) : undefined;
|
|
154
|
+
}
|
|
155
|
+
/** The output of a task as text for the model or the screen. */
|
|
156
|
+
read(id, options = {}) {
|
|
157
|
+
const task = this.must(id);
|
|
158
|
+
const grep = compileRegex(options.grep);
|
|
159
|
+
let from;
|
|
160
|
+
if (options.mode === 'tail')
|
|
161
|
+
from = task.trimmed;
|
|
162
|
+
else
|
|
163
|
+
from = Math.max(task.readCursor, task.trimmed);
|
|
164
|
+
let text = task.buffer.slice(from - task.trimmed);
|
|
165
|
+
if (options.mode !== 'tail')
|
|
166
|
+
task.readCursor = task.trimmed + task.buffer.length;
|
|
167
|
+
let lines = cleanOutput(text).split('\n');
|
|
168
|
+
if (lines.length && lines[lines.length - 1] === '')
|
|
169
|
+
lines.pop();
|
|
170
|
+
if (grep)
|
|
171
|
+
lines = lines.filter((l) => grep.test(l));
|
|
172
|
+
const want = Math.max(1, Math.min(Math.floor(options.lines ?? (options.mode === 'tail' ? 40 : 200)), 1000));
|
|
173
|
+
let omitted = 0;
|
|
174
|
+
if (lines.length > want) {
|
|
175
|
+
omitted = lines.length - want;
|
|
176
|
+
lines = lines.slice(-want);
|
|
177
|
+
}
|
|
178
|
+
text = lines.join('\n');
|
|
179
|
+
if (text.length > MAX_READ_CHARS) {
|
|
180
|
+
omitted += text.slice(0, text.length - MAX_READ_CHARS).split('\n').length - 1;
|
|
181
|
+
text = text.slice(-MAX_READ_CHARS).replace(/^[^\n]*\n/, '');
|
|
182
|
+
}
|
|
183
|
+
return { text, omitted };
|
|
184
|
+
}
|
|
185
|
+
/**
|
|
186
|
+
* Waits until the task ends, a line matches `pattern`, the time is up or the turn is cancelled.
|
|
187
|
+
* Lines the model has already read do not count; lines it has not read yet do.
|
|
188
|
+
*/
|
|
189
|
+
wait(id, options = {}) {
|
|
190
|
+
const task = this.must(id);
|
|
191
|
+
const pattern = compileRegex(options.pattern);
|
|
192
|
+
const timeoutMs = Math.min(Math.max(options.timeoutSeconds ?? 60, 1), 600) * 1000;
|
|
193
|
+
return new Promise((resolve) => {
|
|
194
|
+
let timer;
|
|
195
|
+
// Output nobody has read yet counts: a line that came while the model was busy elsewhere is not lost.
|
|
196
|
+
let scan = Math.max(task.readCursor, task.trimmed);
|
|
197
|
+
const finish = (result) => {
|
|
198
|
+
clearTimeout(timer);
|
|
199
|
+
options.signal?.removeEventListener('abort', onAbort);
|
|
200
|
+
this.removeListener(`data:${id}`, onData);
|
|
201
|
+
resolve(result);
|
|
202
|
+
};
|
|
203
|
+
const onAbort = () => finish({ reason: 'aborted' });
|
|
204
|
+
const onClose = () => finish({ reason: 'exit' });
|
|
205
|
+
void task.done.then(onClose);
|
|
206
|
+
const onData = () => {
|
|
207
|
+
if (!pattern)
|
|
208
|
+
return;
|
|
209
|
+
const start = Math.max(scan, task.trimmed);
|
|
210
|
+
const chunk = cleanOutput(task.buffer.slice(start - task.trimmed));
|
|
211
|
+
const lastNl = chunk.lastIndexOf('\n');
|
|
212
|
+
if (lastNl < 0)
|
|
213
|
+
return;
|
|
214
|
+
scan = start + lastNl + 1;
|
|
215
|
+
const hit = chunk.slice(0, lastNl).split('\n').find((l) => pattern.test(l));
|
|
216
|
+
if (hit !== undefined)
|
|
217
|
+
finish({ reason: 'match', line: hit.trim() });
|
|
218
|
+
};
|
|
219
|
+
if (task.info.status !== 'running')
|
|
220
|
+
return resolve({ reason: 'exit' });
|
|
221
|
+
if (options.signal?.aborted)
|
|
222
|
+
return resolve({ reason: 'aborted' });
|
|
223
|
+
timer = setTimeout(() => finish({ reason: 'timeout' }), timeoutMs);
|
|
224
|
+
options.signal?.addEventListener('abort', onAbort, { once: true });
|
|
225
|
+
this.on(`data:${id}`, onData);
|
|
226
|
+
onData();
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
// ───────────────────────── notes for the model ─────────────────────────
|
|
230
|
+
hasNotes() {
|
|
231
|
+
return this.notes.length > 0;
|
|
232
|
+
}
|
|
233
|
+
/** The notes since the last call, as one message for the model (or '' when there are none). */
|
|
234
|
+
takeNotes() {
|
|
235
|
+
if (this.notes.length === 0)
|
|
236
|
+
return '';
|
|
237
|
+
const text = this.notes.map((n) => n.text).join('\n\n');
|
|
238
|
+
this.notes = [];
|
|
239
|
+
return `[devx · background tasks]\n${text}`;
|
|
240
|
+
}
|
|
241
|
+
// ───────────────────────── internals ─────────────────────────
|
|
242
|
+
must(id) {
|
|
243
|
+
const task = this.tasks.get(String(id));
|
|
244
|
+
if (!task) {
|
|
245
|
+
const known = [...this.tasks.keys()].join(', ');
|
|
246
|
+
throw new Error(`No background task "${id}"${known ? ` (known: ${known})` : ' (none started)'}`);
|
|
247
|
+
}
|
|
248
|
+
return task;
|
|
249
|
+
}
|
|
250
|
+
snapshot(task) {
|
|
251
|
+
return { ...task.info, outputChars: task.trimmed + task.buffer.length };
|
|
252
|
+
}
|
|
253
|
+
append(task, text) {
|
|
254
|
+
if (!text)
|
|
255
|
+
return;
|
|
256
|
+
task.buffer += text;
|
|
257
|
+
if (task.buffer.length > MAX_BUFFER * 1.5) {
|
|
258
|
+
const cut = task.buffer.length - MAX_BUFFER;
|
|
259
|
+
task.buffer = task.buffer.slice(cut);
|
|
260
|
+
task.trimmed += cut;
|
|
261
|
+
}
|
|
262
|
+
if (task.watch)
|
|
263
|
+
this.scanLines(task, text);
|
|
264
|
+
this.emit(`data:${task.info.id}`);
|
|
265
|
+
this.emit('output', task.info.id);
|
|
266
|
+
}
|
|
267
|
+
scanLines(task, text) {
|
|
268
|
+
const parts = cleanOutput(task.pendingLine + text).split('\n');
|
|
269
|
+
task.pendingLine = parts.pop() ?? '';
|
|
270
|
+
for (const line of parts) {
|
|
271
|
+
if (task.watch.test(line))
|
|
272
|
+
this.matched(task, line);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
flushPendingLine(task) {
|
|
276
|
+
if (task.watch && task.pendingLine && task.watch.test(task.pendingLine))
|
|
277
|
+
this.matched(task, task.pendingLine);
|
|
278
|
+
task.pendingLine = '';
|
|
279
|
+
}
|
|
280
|
+
matched(task, line) {
|
|
281
|
+
task.matchedLines.push(line.trim().slice(0, 300));
|
|
282
|
+
if (task.matchedLines.length > 20)
|
|
283
|
+
task.matchedLines.shift();
|
|
284
|
+
clearTimeout(task.matchTimer);
|
|
285
|
+
task.matchTimer = setTimeout(() => this.flushMatches(task), MATCH_DEBOUNCE_MS);
|
|
286
|
+
task.matchTimer.unref();
|
|
287
|
+
}
|
|
288
|
+
flushMatches(task) {
|
|
289
|
+
clearTimeout(task.matchTimer);
|
|
290
|
+
if (task.matchedLines.length === 0)
|
|
291
|
+
return;
|
|
292
|
+
const lines = task.matchedLines.splice(0);
|
|
293
|
+
const shown = lines.slice(-5).map((l) => ` ${l}`).join('\n');
|
|
294
|
+
this.addNote(task, 'match', {
|
|
295
|
+
summary: `${task.info.id} · ${lines[lines.length - 1]}`,
|
|
296
|
+
text: `${task.info.id} "${task.info.label}" printed ${lines.length} line${lines.length === 1 ? '' : 's'} matching /${task.info.watching}/:\n${shown}`,
|
|
297
|
+
});
|
|
298
|
+
}
|
|
299
|
+
exitNote(task) {
|
|
300
|
+
const { id, label, code, startedAt, endedAt } = task.info;
|
|
301
|
+
const how = task.timedOut ? 'was stopped: its time limit ran out' : code === 0 ? 'finished (exit code 0)' : code === null ? 'stopped unexpectedly' : `failed (exit code ${code})`;
|
|
302
|
+
const after = formatDuration((endedAt ?? Date.now()) - startedAt);
|
|
303
|
+
const tail = this.tailForNote(task);
|
|
304
|
+
return {
|
|
305
|
+
summary: `${id} ${code === 0 ? 'finished' : code === null ? 'stopped' : `failed · exit ${code}`} · ${after}`,
|
|
306
|
+
text: `${id} "${label}" ${how} after ${after}.${tail ? `\nLast output:\n${tail}` : ''}`,
|
|
307
|
+
};
|
|
308
|
+
}
|
|
309
|
+
tailForNote(task) {
|
|
310
|
+
const text = cleanOutput(task.buffer).trimEnd();
|
|
311
|
+
return text.length > NOTE_TAIL_CHARS ? '…' + text.slice(-NOTE_TAIL_CHARS).replace(/^[^\n]*\n/, '') : text;
|
|
312
|
+
}
|
|
313
|
+
addNote(task, kind, body) {
|
|
314
|
+
task.notes++;
|
|
315
|
+
// A chatty watcher must not flood the conversation: after a dozen notes only the end is reported.
|
|
316
|
+
if (kind === 'match' && task.notes > MAX_NOTES_PER_TASK)
|
|
317
|
+
return;
|
|
318
|
+
const note = { taskId: task.info.id, kind, ...body };
|
|
319
|
+
this.notes.push(note);
|
|
320
|
+
this.emit('note', note);
|
|
321
|
+
}
|
|
322
|
+
killTree(task, force) {
|
|
323
|
+
const pid = task.proc.pid;
|
|
324
|
+
if (!pid || task.info.status !== 'running')
|
|
325
|
+
return;
|
|
326
|
+
if (process.platform === 'win32') {
|
|
327
|
+
const killer = spawn('taskkill', ['/pid', String(pid), '/T', '/F'], { windowsHide: true, stdio: 'ignore' });
|
|
328
|
+
killer.on('error', () => task.proc.kill());
|
|
329
|
+
return;
|
|
330
|
+
}
|
|
331
|
+
try {
|
|
332
|
+
process.kill(-pid, force ? 'SIGKILL' : 'SIGTERM');
|
|
333
|
+
}
|
|
334
|
+
catch (err) {
|
|
335
|
+
if (err.code !== 'ESRCH') {
|
|
336
|
+
try {
|
|
337
|
+
task.proc.kill(force ? 'SIGKILL' : 'SIGTERM');
|
|
338
|
+
}
|
|
339
|
+
catch { }
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
/** Nothing started by devx may outlive it. */
|
|
344
|
+
installExitHook() {
|
|
345
|
+
liveManagers.add(this);
|
|
346
|
+
if (exitHookInstalled)
|
|
347
|
+
return;
|
|
348
|
+
exitHookInstalled = true;
|
|
349
|
+
process.on('exit', () => {
|
|
350
|
+
for (const manager of liveManagers)
|
|
351
|
+
manager.killAllNow();
|
|
352
|
+
});
|
|
353
|
+
}
|
|
354
|
+
/** Synchronous: used when the process is exiting. */
|
|
355
|
+
killAllNow() {
|
|
356
|
+
for (const task of this.tasks.values()) {
|
|
357
|
+
if (task.info.status === 'running')
|
|
358
|
+
this.killTree(task, true);
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
const liveManagers = new Set();
|
|
363
|
+
let exitHookInstalled = false;
|
|
364
|
+
let instance = null;
|
|
365
|
+
export function getBackgroundManager() {
|
|
366
|
+
if (!instance) {
|
|
367
|
+
instance = new BackgroundManager();
|
|
368
|
+
instance.setMaxListeners(50);
|
|
369
|
+
}
|
|
370
|
+
return instance;
|
|
371
|
+
}
|
|
372
|
+
/** For tests: a fresh manager. */
|
|
373
|
+
export function resetBackgroundManager() {
|
|
374
|
+
void instance?.stopAll();
|
|
375
|
+
instance = null;
|
|
376
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Saving the user's config when several devx processes run at once.
|
|
3
|
+
*
|
|
4
|
+
* Every process keeps its own copy of ~/.devxrc.json in memory. Writing that copy back whole would
|
|
5
|
+
* let the process that saves last overwrite what the others changed meanwhile (theme, API keys,
|
|
6
|
+
* "Always" permissions, the default model...). So a save writes only what THIS process changed
|
|
7
|
+
* since it last looked at the file (`base` -> `mine`) on top of what is on disk now (`disk`).
|
|
8
|
+
*/
|
|
9
|
+
const isPlain = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
|
|
10
|
+
function isPrimitiveList(v) {
|
|
11
|
+
return Array.isArray(v) && v.every((x) => x === null || ['string', 'number', 'boolean'].includes(typeof x));
|
|
12
|
+
}
|
|
13
|
+
/** Structural equality for JSON-like values. */
|
|
14
|
+
export function sameValue(a, b) {
|
|
15
|
+
if (a === b)
|
|
16
|
+
return true;
|
|
17
|
+
if (Array.isArray(a) && Array.isArray(b))
|
|
18
|
+
return a.length === b.length && a.every((x, i) => sameValue(x, b[i]));
|
|
19
|
+
if (isPlain(a) && isPlain(b)) {
|
|
20
|
+
const keys = new Set([...Object.keys(a), ...Object.keys(b)]);
|
|
21
|
+
for (const k of keys)
|
|
22
|
+
if (!sameValue(a[k], b[k]))
|
|
23
|
+
return false;
|
|
24
|
+
return true;
|
|
25
|
+
}
|
|
26
|
+
return false;
|
|
27
|
+
}
|
|
28
|
+
/** A deep copy that is safe to keep as a baseline. */
|
|
29
|
+
export function cloneConfig(value) {
|
|
30
|
+
return value === undefined ? value : JSON.parse(JSON.stringify(value));
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* `disk` with the changes that `base` -> `mine` made applied to it.
|
|
34
|
+
* - a value that did not change is left as it is on disk (another process may have changed it);
|
|
35
|
+
* - objects (apiKeys, trustedProjects...) are merged key by key;
|
|
36
|
+
* - lists of plain values (the bash allowlist) get what this process added and lose what it removed.
|
|
37
|
+
*/
|
|
38
|
+
export function mergeConfigChanges(base, mine, disk) {
|
|
39
|
+
const result = { ...disk };
|
|
40
|
+
const keys = new Set([...Object.keys(base), ...Object.keys(mine)]);
|
|
41
|
+
for (const key of keys) {
|
|
42
|
+
const b = base[key];
|
|
43
|
+
const m = mine[key];
|
|
44
|
+
if (sameValue(b, m))
|
|
45
|
+
continue;
|
|
46
|
+
if (m === undefined) {
|
|
47
|
+
delete result[key];
|
|
48
|
+
}
|
|
49
|
+
else if (isPlain(m) && (b === undefined || isPlain(b)) && (disk[key] === undefined || isPlain(disk[key]))) {
|
|
50
|
+
result[key] = mergeConfigChanges(isPlain(b) ? b : {}, m, isPlain(disk[key]) ? disk[key] : {});
|
|
51
|
+
}
|
|
52
|
+
else if (isPrimitiveList(m) && (b === undefined || isPrimitiveList(b)) && (disk[key] === undefined || isPrimitiveList(disk[key]))) {
|
|
53
|
+
const old = b ?? [];
|
|
54
|
+
const now = m;
|
|
55
|
+
const added = now.filter((x) => !old.includes(x));
|
|
56
|
+
const removed = old.filter((x) => !now.includes(x));
|
|
57
|
+
// Emptying a list ("/permissions clear") really empties it.
|
|
58
|
+
result[key] = m.length === 0
|
|
59
|
+
? []
|
|
60
|
+
: [...new Set([...(disk[key] ?? []).filter((x) => !removed.includes(x)), ...added])];
|
|
61
|
+
}
|
|
62
|
+
else {
|
|
63
|
+
result[key] = m;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return result;
|
|
67
|
+
}
|
package/dist/core/loop.js
CHANGED
|
@@ -32,6 +32,17 @@ export function getActionDescription(toolName, args) {
|
|
|
32
32
|
if (t === 'bash' || t === 'execute_command' || t === 'run_command' || t === 'exec') {
|
|
33
33
|
return `→ Run: ${args.command || args.cmd || ''}`;
|
|
34
34
|
}
|
|
35
|
+
if (t === 'background') {
|
|
36
|
+
const id = args.id ? ` ${args.id}` : '';
|
|
37
|
+
switch (args.action) {
|
|
38
|
+
case 'start': return `→ Background: ${args.label ? `${args.label} · ` : ''}${args.command || ''}`;
|
|
39
|
+
case 'list': return `→ Background: list tasks`;
|
|
40
|
+
case 'output': return `→ Background: output${id}`;
|
|
41
|
+
case 'wait': return `→ Background: wait${id}${args.pattern ? ` for /${args.pattern}/` : ''}`;
|
|
42
|
+
case 'stop': return `→ Background: stop${id}`;
|
|
43
|
+
default: return `→ Background`;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
35
46
|
if (t === 'todo_list' || t === 'update_todos') {
|
|
36
47
|
return `→ Update Plan & Tasks`;
|
|
37
48
|
}
|
|
@@ -83,6 +94,12 @@ export class Agent {
|
|
|
83
94
|
this.history.addMessage(message);
|
|
84
95
|
yield { type: 'user_message', content: message.content };
|
|
85
96
|
}
|
|
97
|
+
// A background task ended or printed what was asked for: the model learns it before it decides.
|
|
98
|
+
const news = hooks?.takeNotes?.();
|
|
99
|
+
if (news) {
|
|
100
|
+
this.history.addMessage({ role: 'user', content: news });
|
|
101
|
+
yield { type: 'background_note', content: news };
|
|
102
|
+
}
|
|
86
103
|
const toolDefinitions = Array.from(this.tools.values()).map((t) => t.definition);
|
|
87
104
|
// Tool schemas are sent with every request and count against the context window.
|
|
88
105
|
const schemaTokens = toolSchemaTokens(toolDefinitions);
|
|
@@ -207,7 +224,7 @@ export class Agent {
|
|
|
207
224
|
}
|
|
208
225
|
if (!response.toolCalls || response.toolCalls.length === 0) {
|
|
209
226
|
// The user wrote something while the model was finishing: answer it instead of stopping.
|
|
210
|
-
if (hooks?.hasPending?.()) {
|
|
227
|
+
if (hooks?.hasPending?.() || hooks?.hasNotes?.()) {
|
|
211
228
|
iterations++;
|
|
212
229
|
continue;
|
|
213
230
|
}
|
package/dist/core/types.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export const DEVX_VERSION = '1.8.
|
|
1
|
+
export const DEVX_VERSION = '1.8.2';
|
|
@@ -277,7 +277,7 @@ function hasComplexChainingOrRedirection(commandStr) {
|
|
|
277
277
|
commandStr.includes('\r'));
|
|
278
278
|
}
|
|
279
279
|
// ───────────────────────── guard ─────────────────────────
|
|
280
|
-
const SHELL_TOOLS = new Set(['bash', 'exec', 'run_command', 'execute_command']);
|
|
280
|
+
const SHELL_TOOLS = new Set(['bash', 'background', 'exec', 'run_command', 'execute_command']);
|
|
281
281
|
const READ_TOOLS = new Set(['read_file', 'view_file', 'read', 'list_dir', 'list_files', 'ls', 'search', 'grep_search', 'grep', 'diagnose_code']);
|
|
282
282
|
const WRITE_TOOLS = new Set(['write_file', 'edit_file', 'make_dir', 'mkdir', 'create_file', 'write_to_file', 'patch']);
|
|
283
283
|
function pathArg(args) {
|
|
@@ -409,6 +409,8 @@ export function buildPermissionRequest(toolName, args) {
|
|
|
409
409
|
notes.push('looks destructive or runs downloaded code');
|
|
410
410
|
else if (shellTouchesSecrets(cmd))
|
|
411
411
|
notes.push('reads a credentials / secrets file: its content would reach the model');
|
|
412
|
+
if (t === 'background')
|
|
413
|
+
notes.push('keeps running in the background until it ends or you stop it');
|
|
412
414
|
return { title: t === 'bash' ? 'bash' : toolName, danger, lines: cmd.split('\n'), notes };
|
|
413
415
|
}
|
|
414
416
|
if (t === 'mcp_manage') {
|
package/dist/prompts/builder.js
CHANGED
|
@@ -34,7 +34,8 @@ export async function buildSystemPrompt(planMode) {
|
|
|
34
34
|
prompt += `- Before using tools on a task, say in one short line what you are about to do. Do not narrate every step.\n`;
|
|
35
35
|
prompt += `- Do the whole task step by step. For a task with several steps, keep the checklist with 'todo_list'.\n`;
|
|
36
36
|
prompt += `- Read a file before you edit it. Change only what the task needs, with 'edit_file' for small changes. Do not rewrite a whole file or touch unrelated code.\n`;
|
|
37
|
-
prompt += `- 'bash' stops after 120 s and cuts long output: give long
|
|
37
|
+
prompt += `- 'bash' stops after 120 s and cuts long output: give a long job a timeout, and print only what you need (head, tail, grep).\n`;
|
|
38
|
+
prompt += `- Anything that keeps running (dev server, watcher, long test run or build) goes to the 'background' tool, not to 'bash': action 'start' returns at once and the work goes on while you continue. Set notify_on to a regex ('ready|listening|error|FAIL') to be told when such a line appears; you are always told when a task ends. Then do other work or finish your answer: do not poll. To wait for a line use action 'wait' with a pattern, to read output use 'output', to end it use 'stop'. The user sees the tasks under /tasks. Never start the same server twice: check 'list' first.\n`;
|
|
38
39
|
prompt += `- After you change code, run 'diagnose_code' and fix what it reports. Install missing dependencies with 'install_package'.\n`;
|
|
39
40
|
prompt += `- After you create or change a web page or frontend app, start the local preview with 'serve_preview' (action 'start').\n`;
|
|
40
41
|
if (MemoryManager.enabled)
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { MAX_BACKGROUND_TASKS, formatDuration, getBackgroundManager } from '../core/background.js';
|
|
2
|
+
const ACTIONS = ['start', 'list', 'output', 'wait', 'stop'];
|
|
3
|
+
/** One line per task: `bg1 running 2m 03s npm run dev`. */
|
|
4
|
+
export function describeTask(info, now = Date.now()) {
|
|
5
|
+
const age = formatDuration((info.endedAt ?? now) - info.startedAt);
|
|
6
|
+
const state = info.status === 'running' ? 'running'
|
|
7
|
+
: info.status === 'stopped' ? 'stopped'
|
|
8
|
+
: info.status === 'exited' ? 'finished (exit 0)'
|
|
9
|
+
: `failed (exit ${info.code ?? '?'})`;
|
|
10
|
+
const watch = info.watching && info.status === 'running' ? ` [watching /${info.watching}/]` : '';
|
|
11
|
+
return `${info.id} ${state} ${age} ${info.label}${watch}`;
|
|
12
|
+
}
|
|
13
|
+
function outputBlock(text, omitted) {
|
|
14
|
+
if (!text)
|
|
15
|
+
return '(no output)';
|
|
16
|
+
return (omitted > 0 ? `[... ${omitted} earlier line${omitted === 1 ? '' : 's'} left out ...]\n` : '') + text;
|
|
17
|
+
}
|
|
18
|
+
export const backgroundTool = {
|
|
19
|
+
name: 'background',
|
|
20
|
+
definition: {
|
|
21
|
+
name: 'background',
|
|
22
|
+
description: 'Run a long-lived command in the background and keep working: dev servers, file watchers, long builds or test runs. ' +
|
|
23
|
+
'Do NOT use it for commands that finish quickly (use bash). Actions: ' +
|
|
24
|
+
'"start" (command, optional label, notify_on, max_runtime_seconds) launches it and returns its id and first output; ' +
|
|
25
|
+
'"list" shows all tasks; ' +
|
|
26
|
+
'"output" (id) reads what the task printed since the last read (mode "tail" gives the last lines instead; "grep" filters lines); ' +
|
|
27
|
+
'"wait" (id, optional pattern, timeout_seconds) blocks until the task ends, a line matches the regex, or the time is up; ' +
|
|
28
|
+
'"stop" (id) ends the task. ' +
|
|
29
|
+
'Set "notify_on" (a regex such as "ready|listening|error|FAIL") to be told when such a line appears, and you are always told when a task ends: ' +
|
|
30
|
+
'then you do not need to poll. Never poll in a loop; wait with a pattern instead. ' +
|
|
31
|
+
`At most ${MAX_BACKGROUND_TASKS} tasks run at once, and all of them are stopped when devx exits.`,
|
|
32
|
+
parameters: {
|
|
33
|
+
type: 'object',
|
|
34
|
+
properties: {
|
|
35
|
+
action: { type: 'string', enum: [...ACTIONS], description: 'What to do' },
|
|
36
|
+
command: { type: 'string', description: 'start: the shell command' },
|
|
37
|
+
label: { type: 'string', description: 'start: a short name shown to the user, e.g. "dev server"' },
|
|
38
|
+
notify_on: { type: 'string', description: 'start: regex; a matching output line is reported to you' },
|
|
39
|
+
max_runtime_seconds: { type: 'number', description: 'start: stop the task after this many seconds (default: no limit)' },
|
|
40
|
+
id: { type: 'string', description: 'output / wait / stop: the task id (bg1, bg2, ...)' },
|
|
41
|
+
mode: { type: 'string', enum: ['new', 'tail'], description: 'output: "new" (default) = since the last read, "tail" = last lines' },
|
|
42
|
+
lines: { type: 'number', description: 'output: how many lines at most (default 200 for new, 40 for tail)' },
|
|
43
|
+
grep: { type: 'string', description: 'output: only lines that match this regex' },
|
|
44
|
+
pattern: { type: 'string', description: 'wait: regex of an output line to wait for (default: wait for the end)' },
|
|
45
|
+
timeout_seconds: { type: 'number', description: 'wait: give up after this long (default 60, max 600)' },
|
|
46
|
+
},
|
|
47
|
+
required: ['action'],
|
|
48
|
+
},
|
|
49
|
+
},
|
|
50
|
+
validateArgs(args) {
|
|
51
|
+
if (!args || !ACTIONS.includes(args.action))
|
|
52
|
+
throw new Error(`action must be one of: ${ACTIONS.join(', ')}`);
|
|
53
|
+
if (args.action === 'start' && (!args.command || typeof args.command !== 'string'))
|
|
54
|
+
throw new Error('command is required for "start"');
|
|
55
|
+
if (['output', 'wait', 'stop'].includes(args.action) && !args.id)
|
|
56
|
+
throw new Error(`id is required for "${args.action}" (see action "list")`);
|
|
57
|
+
},
|
|
58
|
+
async execute(args, _config, context) {
|
|
59
|
+
const manager = getBackgroundManager();
|
|
60
|
+
const action = args.action;
|
|
61
|
+
try {
|
|
62
|
+
if (action === 'start') {
|
|
63
|
+
const info = manager.start(args.command, {
|
|
64
|
+
label: args.label,
|
|
65
|
+
notifyOn: args.notify_on,
|
|
66
|
+
maxRuntimeSeconds: Number(args.max_runtime_seconds) || 0,
|
|
67
|
+
});
|
|
68
|
+
// Give it a moment: a command that dies at once (wrong script, busy port) should be seen now.
|
|
69
|
+
const first = await manager.wait(info.id, { timeoutSeconds: 1.5, signal: context?.signal });
|
|
70
|
+
const now = manager.get(info.id);
|
|
71
|
+
const out = manager.read(info.id, { mode: 'new', lines: 30 });
|
|
72
|
+
const head = now.status === 'running'
|
|
73
|
+
? `Started ${now.id} (pid ${now.pid ?? '?'}): ${now.label}. It runs in the background.`
|
|
74
|
+
: `${describeTask(now)} - it ended right after the start.`;
|
|
75
|
+
const hint = now.status === 'running'
|
|
76
|
+
? '\nYou will be told when it ends' + (now.watching ? ' or prints a line matching the pattern' : '') + '. Use "output" to read more, "wait" to wait for a line.'
|
|
77
|
+
: '';
|
|
78
|
+
return `${head}${hint}\n${outputBlock(out.text, out.omitted)}${first.reason === 'aborted' ? '\n[Operation cancelled by user]' : ''}`;
|
|
79
|
+
}
|
|
80
|
+
if (action === 'list') {
|
|
81
|
+
const tasks = manager.list();
|
|
82
|
+
return tasks.length === 0 ? 'No background tasks.' : tasks.map((t) => describeTask(t)).join('\n');
|
|
83
|
+
}
|
|
84
|
+
if (action === 'output') {
|
|
85
|
+
const info = manager.get(String(args.id));
|
|
86
|
+
const out = manager.read(String(args.id), {
|
|
87
|
+
mode: args.mode === 'tail' ? 'tail' : 'new',
|
|
88
|
+
lines: args.lines,
|
|
89
|
+
grep: args.grep,
|
|
90
|
+
});
|
|
91
|
+
return `${info ? describeTask(info) : args.id}\n${outputBlock(out.text, out.omitted)}`;
|
|
92
|
+
}
|
|
93
|
+
if (action === 'wait') {
|
|
94
|
+
const result = await manager.wait(String(args.id), {
|
|
95
|
+
pattern: args.pattern,
|
|
96
|
+
timeoutSeconds: Number(args.timeout_seconds) || 60,
|
|
97
|
+
signal: context?.signal,
|
|
98
|
+
});
|
|
99
|
+
const info = manager.get(String(args.id));
|
|
100
|
+
const out = manager.read(String(args.id), { mode: 'new', lines: 60 });
|
|
101
|
+
const why = result.reason === 'match' ? `A line matched: ${result.line}`
|
|
102
|
+
: result.reason === 'exit' ? 'The task has ended.'
|
|
103
|
+
: result.reason === 'timeout' ? 'Timed out; the task is still running.'
|
|
104
|
+
: '[Operation cancelled by user]';
|
|
105
|
+
return `${why}\n${describeTask(info)}\n${outputBlock(out.text, out.omitted)}`;
|
|
106
|
+
}
|
|
107
|
+
// stop
|
|
108
|
+
const before = manager.get(String(args.id));
|
|
109
|
+
if (before && before.status !== 'running')
|
|
110
|
+
return `${describeTask(before)} (already ended)`;
|
|
111
|
+
const info = await manager.stop(String(args.id));
|
|
112
|
+
return `Stopped. ${describeTask(info)}`;
|
|
113
|
+
}
|
|
114
|
+
catch (err) {
|
|
115
|
+
return `Error: ${err.message}`;
|
|
116
|
+
}
|
|
117
|
+
},
|
|
118
|
+
};
|