@parallel-cli/parallel 0.4.1 → 0.4.4
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/CHANGELOG.md +149 -0
- package/README.md +183 -195
- package/dist/agents/agent.js +2 -2
- package/dist/agents/tools.js +47 -5
- package/dist/commands.js +117 -18
- package/dist/config.js +248 -63
- package/dist/controller.js +48 -17
- package/dist/i18n.js +192 -44
- package/dist/index.js +8 -5
- package/dist/pricing.js +162 -54
- package/dist/ui/App.js +208 -102
- package/dist/ui/CommandInput.js +42 -17
- package/dist/ui/SettingsPanel.js +224 -34
- package/dist/ui/Wizard.js +33 -3
- package/dist/ui/views.js +53 -21
- package/package.json +10 -1
package/dist/agents/tools.js
CHANGED
|
@@ -4,6 +4,19 @@ import { exec } from 'node:child_process';
|
|
|
4
4
|
import * as Diff from 'diff';
|
|
5
5
|
const IGNORED = new Set(['node_modules', '.git', '.parallel', 'dist', '__pycache__', '.venv', 'venv']);
|
|
6
6
|
const MAX_OUTPUT = 12_000;
|
|
7
|
+
const MUTATING_TOOLS = new Set(['write_file', 'edit_file', 'claim_files', 'remember']);
|
|
8
|
+
function isMutatingShell(command) {
|
|
9
|
+
const c = command.toLowerCase();
|
|
10
|
+
if (/\b(rm|mv|cp|chmod|chown|mkdir|touch|truncate)\b/.test(c))
|
|
11
|
+
return true;
|
|
12
|
+
if (/\b(git\s+(add|commit|push|pull|merge|rebase|checkout|switch|reset|clean|stash|tag))\b/.test(c))
|
|
13
|
+
return true;
|
|
14
|
+
if (/\b(npm|pnpm|yarn)\s+(install|add|remove|update|audit\s+fix)\b/.test(c))
|
|
15
|
+
return true;
|
|
16
|
+
if (/[>|]\s*(sh|bash)\b/.test(c) || /\b(curl|wget)\b.*\|\s*(sh|bash)/.test(c))
|
|
17
|
+
return true;
|
|
18
|
+
return false;
|
|
19
|
+
}
|
|
7
20
|
export const TOOL_DEFINITIONS = [
|
|
8
21
|
{
|
|
9
22
|
type: 'function',
|
|
@@ -228,11 +241,13 @@ export class ToolExecutor {
|
|
|
228
241
|
requestApproval;
|
|
229
242
|
requestQuestion;
|
|
230
243
|
skills;
|
|
244
|
+
mode;
|
|
231
245
|
/** Last content this agent has seen for each file — basis of adaptive merging. */
|
|
232
246
|
lastRead = new Map();
|
|
233
247
|
/** Questions already asked — capped at 3 per task. */
|
|
234
248
|
questionsAsked = 0;
|
|
235
|
-
|
|
249
|
+
planApproved = false;
|
|
250
|
+
constructor(board, agentId, agentName, projectRoot, requestApproval, requestQuestion, skills, mode = 'task') {
|
|
236
251
|
this.board = board;
|
|
237
252
|
this.agentId = agentId;
|
|
238
253
|
this.agentName = agentName;
|
|
@@ -240,10 +255,13 @@ export class ToolExecutor {
|
|
|
240
255
|
this.requestApproval = requestApproval;
|
|
241
256
|
this.requestQuestion = requestQuestion;
|
|
242
257
|
this.skills = skills;
|
|
258
|
+
this.mode = mode;
|
|
243
259
|
}
|
|
244
260
|
resolve(rel) {
|
|
245
|
-
const
|
|
246
|
-
|
|
261
|
+
const root = path.resolve(this.projectRoot);
|
|
262
|
+
const abs = path.resolve(root, rel);
|
|
263
|
+
const relative = path.relative(root, abs);
|
|
264
|
+
if (relative.startsWith('..') || path.isAbsolute(relative)) {
|
|
247
265
|
throw new Error(`Path outside the project refused: ${rel}`);
|
|
248
266
|
}
|
|
249
267
|
return abs;
|
|
@@ -253,6 +271,9 @@ export class ToolExecutor {
|
|
|
253
271
|
}
|
|
254
272
|
async execute(name, args) {
|
|
255
273
|
try {
|
|
274
|
+
const guard = this.guardTool(name, args);
|
|
275
|
+
if (guard)
|
|
276
|
+
return guard;
|
|
256
277
|
switch (name) {
|
|
257
278
|
case 'list_files':
|
|
258
279
|
return this.listFiles(args?.path ?? '.');
|
|
@@ -293,6 +314,22 @@ export class ToolExecutor {
|
|
|
293
314
|
return `ERROR: ${err?.message ?? String(err)}`;
|
|
294
315
|
}
|
|
295
316
|
}
|
|
317
|
+
guardTool(name, args) {
|
|
318
|
+
if (this.mode === 'ask') {
|
|
319
|
+
if (MUTATING_TOOLS.has(name) || name === 'run_command') {
|
|
320
|
+
return `DENIED: this agent is in /ask mode. It can inspect and advise, but cannot modify files, run shell commands, claim files, or write memory.`;
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
if (this.mode === 'plan' && !this.planApproved) {
|
|
324
|
+
if (MUTATING_TOOLS.has(name)) {
|
|
325
|
+
return `DENIED: this agent is in /plan mode and the plan has not been approved yet. Present the plan with ask_user and wait for "Approve" before modifying project state.`;
|
|
326
|
+
}
|
|
327
|
+
if (name === 'run_command' && isMutatingShell(String(args?.command ?? ''))) {
|
|
328
|
+
return `DENIED: this shell command looks mutating. In /plan mode, run only read-only inspection before approval; ask_user for plan approval first.`;
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
return null;
|
|
332
|
+
}
|
|
296
333
|
/**
|
|
297
334
|
* Ask the user a multiple-choice question. NEVER blocks forever: the UI shows
|
|
298
335
|
* a visible 30s countdown and falls back to the recommended option (auto-run).
|
|
@@ -312,9 +349,14 @@ export class ToolExecutor {
|
|
|
312
349
|
this.questionsAsked++;
|
|
313
350
|
this.board.setAgentState(this.agentId, 'waiting', `question: ${question.slice(0, 60)}`);
|
|
314
351
|
this.board.log(this.agentId, 'note', `❓ ${question}`);
|
|
315
|
-
const
|
|
352
|
+
const response = await this.requestQuestion(this.agentId, question, options, recommended);
|
|
353
|
+
const answer = response.answer;
|
|
354
|
+
if (this.mode === 'plan' && !response.auto && answer.trim().toLowerCase().startsWith('approve')) {
|
|
355
|
+
this.planApproved = true;
|
|
356
|
+
}
|
|
316
357
|
this.board.setAgentState(this.agentId, 'working');
|
|
317
|
-
|
|
358
|
+
const source = response.auto ? 'The timeout auto-selected' : 'The user answered';
|
|
359
|
+
return `${source}: "${answer}". Act on this choice now (${3 - this.questionsAsked} question(s) left for this task).`;
|
|
318
360
|
}
|
|
319
361
|
/** Declare (advisory) work areas — visible to the user and the other agents. */
|
|
320
362
|
claimFiles(args) {
|
package/dist/commands.js
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { execFileSync } from 'node:child_process';
|
|
1
4
|
import { Controller, normalizeShellApprovalMode } from './controller.js';
|
|
2
5
|
import { createSkillTemplate, createSpecialistTemplate } from './skills.js';
|
|
6
|
+
import { isLocalProvider, isPlaceholderModel, providerNeedsApiKey } from './config.js';
|
|
3
7
|
import { t } from './i18n.js';
|
|
4
8
|
// Grouped by intent so /help reads as a story: create agents → steer them →
|
|
5
9
|
// inspect the session → git safety net → session & config → exit.
|
|
@@ -41,11 +45,13 @@ export const COMMANDS = [
|
|
|
41
45
|
{ name: '/restore', args: '<agent>', descKey: 'cmd.restore', group: 'git' },
|
|
42
46
|
// config
|
|
43
47
|
{ name: '/model', args: '[[provider:]model]', descKey: 'cmd.model', group: 'settings' },
|
|
44
|
-
{ name: '/key', args: '<key>', descKey: 'cmd.key', group: 'settings' },
|
|
48
|
+
{ name: '/key', args: '<key>', descKey: 'cmd.key', group: 'settings', hidden: true },
|
|
45
49
|
{ name: '/approvals', args: '<ask|auto|auto-safe|yolo>', descKey: 'cmd.approvals', group: 'settings' },
|
|
46
50
|
{ name: '/sound', args: '<on|off>', descKey: 'cmd.sound', group: 'settings' },
|
|
47
51
|
{ name: '/settings', args: '', descKey: 'cmd.settings', group: 'settings' },
|
|
48
52
|
{ name: '/settings-session', args: '', descKey: 'cmd.ssettings', group: 'settings', aliases: ['/ssettings'] },
|
|
53
|
+
{ name: '/project', args: '[folder]', descKey: 'cmd.project', group: 'settings', aliases: ['/folder'] },
|
|
54
|
+
{ name: '/wizard', args: '', descKey: 'cmd.wizard', group: 'settings', aliases: ['/setup'] },
|
|
49
55
|
{ name: '/doctor', args: '', descKey: 'cmd.doctor', group: 'settings' },
|
|
50
56
|
// exit
|
|
51
57
|
{ name: '/help', args: '', descKey: 'cmd.help', group: 'other' },
|
|
@@ -63,6 +69,72 @@ export function matchCommands(input, opts = {}) {
|
|
|
63
69
|
function agentList(ctl) {
|
|
64
70
|
return [...ctl.board.agents.values()].map((a) => a.name).join(', ') || t('m.none');
|
|
65
71
|
}
|
|
72
|
+
function commandExists(name) {
|
|
73
|
+
try {
|
|
74
|
+
execFileSync('which', [name], { stdio: 'ignore' });
|
|
75
|
+
return true;
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
78
|
+
return false;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
async function localEndpointReachable(baseUrl) {
|
|
82
|
+
let timeout;
|
|
83
|
+
try {
|
|
84
|
+
const controller = new AbortController();
|
|
85
|
+
timeout = setTimeout(() => controller.abort(), 1500);
|
|
86
|
+
const resp = await fetch(baseUrl.replace(/\/+$/, '') + '/models', { signal: controller.signal });
|
|
87
|
+
return resp.ok;
|
|
88
|
+
}
|
|
89
|
+
catch {
|
|
90
|
+
return false;
|
|
91
|
+
}
|
|
92
|
+
finally {
|
|
93
|
+
if (timeout)
|
|
94
|
+
clearTimeout(timeout);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
async function doctorReport(ctl, ui) {
|
|
98
|
+
const p = ctl.sessionProvider();
|
|
99
|
+
const lines = [];
|
|
100
|
+
let level = 'ok';
|
|
101
|
+
if (!p) {
|
|
102
|
+
ui.system(t('m.doctorReport', { lines: t('m.doctorNoProvider') }), 'error');
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
const activeModel = ctl.session.model || p.defaultModel || p.models[0] || '';
|
|
106
|
+
lines.push(t('m.doctorProvider', { provider: p.name, model: activeModel || '—' }));
|
|
107
|
+
if (providerNeedsApiKey(p) && !p.apiKey) {
|
|
108
|
+
level = 'error';
|
|
109
|
+
lines.push(t('m.doctorKeyMissing'));
|
|
110
|
+
}
|
|
111
|
+
else {
|
|
112
|
+
lines.push(providerNeedsApiKey(p) ? t('m.doctorKeyOk') : t('m.doctorKeySkipped'));
|
|
113
|
+
}
|
|
114
|
+
if (isPlaceholderModel(activeModel)) {
|
|
115
|
+
level = 'error';
|
|
116
|
+
lines.push(t('m.doctorModelMissing'));
|
|
117
|
+
}
|
|
118
|
+
else {
|
|
119
|
+
lines.push(t('m.doctorModelOk', { model: activeModel }));
|
|
120
|
+
}
|
|
121
|
+
if (isLocalProvider(p)) {
|
|
122
|
+
const reachable = await localEndpointReachable(p.baseUrl);
|
|
123
|
+
if (reachable) {
|
|
124
|
+
lines.push(t('m.doctorEndpointOk', { url: p.baseUrl }));
|
|
125
|
+
}
|
|
126
|
+
else {
|
|
127
|
+
if (level !== 'error')
|
|
128
|
+
level = 'warn';
|
|
129
|
+
lines.push(t('m.doctorEndpointFail', { url: p.baseUrl }));
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
const sock = path.join(ctl.projectRoot, '.parallel', 'session.sock');
|
|
133
|
+
lines.push(fs.existsSync(sock) ? t('m.doctorAttachOk') : t('m.doctorAttachMissing'));
|
|
134
|
+
lines.push(commandExists('git') ? t('m.doctorGitOk') : t('m.doctorGitMissing'));
|
|
135
|
+
lines.push(commandExists('gh') ? t('m.doctorGhOk') : t('m.doctorGhMissing'));
|
|
136
|
+
ui.system(t('m.doctorReport', { lines: lines.join('\n') }), level);
|
|
137
|
+
}
|
|
66
138
|
/**
|
|
67
139
|
* Single-agent ergonomics: when the session has exactly ONE agent, commands
|
|
68
140
|
* that target an agent (/undo, /focus, /pause…) work without naming it.
|
|
@@ -75,10 +147,12 @@ function spawnFrom(arg, ctl, ui, images, specialist, mode = 'task') {
|
|
|
75
147
|
const p = ctl.sessionProvider();
|
|
76
148
|
if (!p)
|
|
77
149
|
return ui.system(t('m.missingProvider'), 'error');
|
|
78
|
-
if (!p.apiKey)
|
|
150
|
+
if (providerNeedsApiKey(p) && !p.apiKey)
|
|
79
151
|
return ui.system(t('m.missingKey', { name: p.name }), 'error');
|
|
80
|
-
|
|
152
|
+
const activeModel = ctl.session.model || p.defaultModel || p.models[0] || '';
|
|
153
|
+
if (isPlaceholderModel(activeModel)) {
|
|
81
154
|
return ui.system(t('m.missingModel', { name: p.name }), 'error');
|
|
155
|
+
}
|
|
82
156
|
// optional --model=xxx flag
|
|
83
157
|
let model;
|
|
84
158
|
let task = arg;
|
|
@@ -126,8 +200,8 @@ export function executeInput(raw, ctl, ui, images) {
|
|
|
126
200
|
return ui.system(t('m.usageAt'), 'warn');
|
|
127
201
|
const [, target, content] = m;
|
|
128
202
|
if (target.toLowerCase() === 'all') {
|
|
129
|
-
ctl.broadcast(content);
|
|
130
|
-
ui.system(t('m.broadcast'), 'ok');
|
|
203
|
+
const n = ctl.broadcast(content);
|
|
204
|
+
ui.system(t('m.broadcast', { n }), n > 0 ? 'ok' : 'warn');
|
|
131
205
|
}
|
|
132
206
|
else if (ctl.sendToAgent(target, content)) {
|
|
133
207
|
ui.system(t('m.sent', { target }), 'ok');
|
|
@@ -155,7 +229,11 @@ export function executeInput(raw, ctl, ui, images) {
|
|
|
155
229
|
? '/settings-session'
|
|
156
230
|
: rawCmd.toLowerCase() === '/exit'
|
|
157
231
|
? '/quit'
|
|
158
|
-
: rawCmd
|
|
232
|
+
: rawCmd.toLowerCase() === '/folder'
|
|
233
|
+
? '/project'
|
|
234
|
+
: rawCmd.toLowerCase() === '/setup'
|
|
235
|
+
? '/wizard'
|
|
236
|
+
: rawCmd;
|
|
159
237
|
const arg = rest.join(' ').trim();
|
|
160
238
|
switch (cmd.toLowerCase()) {
|
|
161
239
|
case '/ask': {
|
|
@@ -210,10 +288,18 @@ export function executeInput(raw, ctl, ui, images) {
|
|
|
210
288
|
case '/commit': {
|
|
211
289
|
// Commit the files touched by one agent (or all) — staged by explicit path.
|
|
212
290
|
const [target0, ...msg] = rest;
|
|
213
|
-
const
|
|
291
|
+
const solo = soloAgent(ctl);
|
|
292
|
+
const target = target0 && (target0.toLowerCase() === 'all' || ctl.board.getAgentByName(target0))
|
|
293
|
+
? target0
|
|
294
|
+
: target0 && solo
|
|
295
|
+
? solo
|
|
296
|
+
: target0 || solo;
|
|
297
|
+
const message = target0 && target === solo && target0.toLowerCase() !== solo?.toLowerCase() && !ctl.board.getAgentByName(target0)
|
|
298
|
+
? rest.join(' ').trim()
|
|
299
|
+
: msg.join(' ').trim();
|
|
214
300
|
if (!target)
|
|
215
301
|
return ui.system(t('m.usageCommit'), 'warn');
|
|
216
|
-
const r = ctl.commitFor(target,
|
|
302
|
+
const r = ctl.commitFor(target, message || undefined);
|
|
217
303
|
if (r.ok)
|
|
218
304
|
return ui.system(t('m.committed', { name: target, files: String(r.files) }), 'ok');
|
|
219
305
|
if (r.reason === 'not-found')
|
|
@@ -242,15 +328,19 @@ export function executeInput(raw, ctl, ui, images) {
|
|
|
242
328
|
ui.setView('sessions');
|
|
243
329
|
return;
|
|
244
330
|
}
|
|
331
|
+
const force = rest.includes('--force');
|
|
332
|
+
const selector = rest.filter((part) => part !== '--force').join(' ').trim();
|
|
245
333
|
const sessions = Controller.listSessions(ctl.projectRoot);
|
|
246
334
|
if (sessions.length === 0)
|
|
247
335
|
return ui.system(t('m.usageSession'), 'warn');
|
|
248
|
-
const idx =
|
|
336
|
+
const idx = selector.toLowerCase() === 'latest' ? 0 : Number.parseInt(selector, 10) - 1;
|
|
249
337
|
const session = sessions[idx];
|
|
250
338
|
if (!session)
|
|
251
339
|
return ui.system(t('m.usageSession'), 'warn');
|
|
340
|
+
if (ctl.hasRunningAgents() && !force)
|
|
341
|
+
return ui.system(t('m.sessionActive'), 'warn');
|
|
252
342
|
ctl.loadSession(session.data);
|
|
253
|
-
ui.system(t('m.sessionLoaded', { date: new Date(session.data.savedAt).toLocaleString() }), 'ok');
|
|
343
|
+
ui.system(t('m.sessionLoaded', { date: new Date(session.data.savedAt).toLocaleString() }) + '\n' + t('m.sessionRestoreHint'), 'ok');
|
|
254
344
|
return;
|
|
255
345
|
}
|
|
256
346
|
case '/restore': {
|
|
@@ -260,6 +350,8 @@ export function executeInput(raw, ctl, ui, images) {
|
|
|
260
350
|
if (!ctl.loadedSession)
|
|
261
351
|
return ui.system(t('m.usageSession'), 'warn');
|
|
262
352
|
const res = ctl.respawnAgent(arg);
|
|
353
|
+
if (res === 'no-agent')
|
|
354
|
+
return ui.system(t('m.noRestoredAgent', { name: arg }), 'error');
|
|
263
355
|
if (res === 'no-conversation')
|
|
264
356
|
return ui.system(t('m.noConversation', { name: arg }), 'error');
|
|
265
357
|
if (!res)
|
|
@@ -308,14 +400,7 @@ export function executeInput(raw, ctl, ui, images) {
|
|
|
308
400
|
return;
|
|
309
401
|
}
|
|
310
402
|
case '/doctor': {
|
|
311
|
-
|
|
312
|
-
if (!p)
|
|
313
|
-
return ui.system(t('m.missingProvider'), 'error');
|
|
314
|
-
if (!p.apiKey)
|
|
315
|
-
return ui.system(t('m.missingKey', { name: p.name }), 'error');
|
|
316
|
-
if (!ctl.session.model && !p.defaultModel && !p.models[0])
|
|
317
|
-
return ui.system(t('m.missingModel', { name: p.name }), 'error');
|
|
318
|
-
ui.system(t('m.doctorOk', { pm: `${p.name}:${ctl.session.model || p.defaultModel || p.models[0]}` }), 'ok');
|
|
403
|
+
void doctorReport(ctl, ui);
|
|
319
404
|
return;
|
|
320
405
|
}
|
|
321
406
|
case '/cost':
|
|
@@ -468,6 +553,20 @@ export function executeInput(raw, ctl, ui, images) {
|
|
|
468
553
|
case '/settings-session':
|
|
469
554
|
ui.setView('settings-session');
|
|
470
555
|
return;
|
|
556
|
+
case '/project':
|
|
557
|
+
{
|
|
558
|
+
const force = rest.includes('--force');
|
|
559
|
+
const folderArg = rest.filter((part) => part !== '--force').join(' ').trim();
|
|
560
|
+
if (ctl.hasRunningAgents() && !force)
|
|
561
|
+
return ui.system(t('m.projectActive'), 'warn');
|
|
562
|
+
ui.openProject?.(folderArg || undefined);
|
|
563
|
+
}
|
|
564
|
+
return;
|
|
565
|
+
case '/wizard':
|
|
566
|
+
if (ctl.hasRunningAgents() && arg !== '--force')
|
|
567
|
+
return ui.system(t('m.wizardActive'), 'warn');
|
|
568
|
+
ui.openWizard?.();
|
|
569
|
+
return;
|
|
471
570
|
// SESSION-only approvals & sound (global defaults editable in /settings).
|
|
472
571
|
case '/approvals': {
|
|
473
572
|
const mode = normalizeShellApprovalMode(arg);
|