@parallel-cli/parallel 0.3.3
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/LICENSE +21 -0
- package/README.md +316 -0
- package/dist/agents/agent.js +518 -0
- package/dist/agents/tools.js +570 -0
- package/dist/commands.js +480 -0
- package/dist/config.js +163 -0
- package/dist/controller.js +703 -0
- package/dist/coordination/blackboard.js +225 -0
- package/dist/i18n.js +1087 -0
- package/dist/index.js +196 -0
- package/dist/llm/client.js +46 -0
- package/dist/pricing.js +76 -0
- package/dist/server.js +149 -0
- package/dist/skills.js +132 -0
- package/dist/types.js +1 -0
- package/dist/ui/AgentPanel.js +25 -0
- package/dist/ui/App.js +400 -0
- package/dist/ui/ApprovalPrompt.js +18 -0
- package/dist/ui/AttachApp.js +126 -0
- package/dist/ui/CommandInput.js +154 -0
- package/dist/ui/Md.js +40 -0
- package/dist/ui/QuestionPrompt.js +58 -0
- package/dist/ui/SettingsPanel.js +217 -0
- package/dist/ui/Spinner.js +12 -0
- package/dist/ui/Wizard.js +66 -0
- package/dist/ui/clipboard.js +36 -0
- package/dist/ui/theme.js +27 -0
- package/dist/ui/views.js +94 -0
- package/package.json +59 -0
package/dist/commands.js
ADDED
|
@@ -0,0 +1,480 @@
|
|
|
1
|
+
import { Controller } from './controller.js';
|
|
2
|
+
import { createSkillTemplate, createSpecialistTemplate } from './skills.js';
|
|
3
|
+
import { t } from './i18n.js';
|
|
4
|
+
// Grouped by intent so /help reads as a story: create agents → steer them →
|
|
5
|
+
// inspect the session → git safety net → session & config → exit.
|
|
6
|
+
export const COMMANDS = [
|
|
7
|
+
// create agents
|
|
8
|
+
{ name: '/spawn', args: '[Name:] <task> [--model=m] [#skill]', descKey: 'cmd.spawn' },
|
|
9
|
+
{ name: '/plan', args: '[Name:] <task> [--model=m]', descKey: 'cmd.plan' },
|
|
10
|
+
{ name: '/issue', args: '<n>', descKey: 'cmd.issue' },
|
|
11
|
+
{ name: '/specialist', args: '<name> <task> | new <name> [global]', descKey: 'cmd.specialist' },
|
|
12
|
+
{ name: '/specialists', args: '', descKey: 'cmd.specialists' },
|
|
13
|
+
{ name: '/skill', args: 'new <name> [global]', descKey: 'cmd.skill' },
|
|
14
|
+
{ name: '/skills', args: '', descKey: 'cmd.skills' },
|
|
15
|
+
// steer agents
|
|
16
|
+
{ name: '/send', args: '<agent|all> <message>', descKey: 'cmd.send' },
|
|
17
|
+
{ name: '/attach', args: '<agent|on|off>', descKey: 'cmd.attach' },
|
|
18
|
+
{ name: '/focus', args: '<agent|off>', descKey: 'cmd.focus' },
|
|
19
|
+
{ name: '/pause', args: '<agent|all>', descKey: 'cmd.pause' },
|
|
20
|
+
{ name: '/resume', args: '<agent|all>', descKey: 'cmd.resume' },
|
|
21
|
+
{ name: '/stop', args: '<agent|all>', descKey: 'cmd.stop' },
|
|
22
|
+
{ name: '/clear', args: '', descKey: 'cmd.clear' },
|
|
23
|
+
// git safety net
|
|
24
|
+
{ name: '/undo', args: '[agent]', descKey: 'cmd.undo' },
|
|
25
|
+
{ name: '/commit', args: '[agent|all] [message]', descKey: 'cmd.commit' },
|
|
26
|
+
{ name: '/autocommit', args: '<on|off>', descKey: 'cmd.autocommit' },
|
|
27
|
+
// inspect the session
|
|
28
|
+
{ name: '/agents', args: '', descKey: 'cmd.agents' },
|
|
29
|
+
{ name: '/board', args: '', descKey: 'cmd.board' },
|
|
30
|
+
{ name: '/notes', args: '', descKey: 'cmd.notes' },
|
|
31
|
+
{ name: '/diff', args: '', descKey: 'cmd.diff' },
|
|
32
|
+
{ name: '/cost', args: '', descKey: 'cmd.cost' },
|
|
33
|
+
// sessions
|
|
34
|
+
{ name: '/save', args: '[name]', descKey: 'cmd.save' },
|
|
35
|
+
{ name: '/sessions', args: '', descKey: 'cmd.sessions' },
|
|
36
|
+
{ name: '/session', args: '[n|latest]', descKey: 'cmd.session' },
|
|
37
|
+
{ name: '/restore', args: '<agent>', descKey: 'cmd.restore' },
|
|
38
|
+
// config
|
|
39
|
+
{ name: '/model', args: '[[provider:]model]', descKey: 'cmd.model' },
|
|
40
|
+
{ name: '/key', args: '<key>', descKey: 'cmd.key' },
|
|
41
|
+
{ name: '/approvals', args: '<ask|auto>', descKey: 'cmd.approvals' },
|
|
42
|
+
{ name: '/sound', args: '<on|off>', descKey: 'cmd.sound' },
|
|
43
|
+
{ name: '/settings', args: '', descKey: 'cmd.settings' },
|
|
44
|
+
{ name: '/settings-session', args: '', descKey: 'cmd.ssettings', aliases: ['/ssettings'] },
|
|
45
|
+
{ name: '/doctor', args: '', descKey: 'cmd.doctor' },
|
|
46
|
+
// exit
|
|
47
|
+
{ name: '/help', args: '', descKey: 'cmd.help' },
|
|
48
|
+
{ name: '/quit', args: '', descKey: 'cmd.quit', aliases: ['/exit'] },
|
|
49
|
+
];
|
|
50
|
+
export function matchCommands(input) {
|
|
51
|
+
if (!input.startsWith('/'))
|
|
52
|
+
return [];
|
|
53
|
+
const word = input.split(/\s+/)[0].toLowerCase();
|
|
54
|
+
return COMMANDS.filter((c) => c.name.startsWith(word) || c.aliases?.some((a) => a.startsWith(word)));
|
|
55
|
+
}
|
|
56
|
+
function agentList(ctl) {
|
|
57
|
+
return [...ctl.board.agents.values()].map((a) => a.name).join(', ') || t('m.none');
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Single-agent ergonomics: when the session has exactly ONE agent, commands
|
|
61
|
+
* that target an agent (/undo, /focus, /pause…) work without naming it.
|
|
62
|
+
*/
|
|
63
|
+
function soloAgent(ctl) {
|
|
64
|
+
const list = [...ctl.board.agents.values()];
|
|
65
|
+
return list.length === 1 ? list[0].name : null;
|
|
66
|
+
}
|
|
67
|
+
/** Appended to the task in plan-first mode (/plan): no edits before approval. */
|
|
68
|
+
const PLAN_FIRST = `
|
|
69
|
+
|
|
70
|
+
PLAN-FIRST MODE — MANDATORY: before modifying ANY file, explore the code (list_files, read_file, search), then present your implementation plan to the user with ask_user: the question is the full plan (steps + files you will touch + risks), the options are ["Approve", "Revise"], recommended = "Approve". If the user answers "Revise", ask what to change (ask_user) and present an updated plan. Start editing files ONLY after an explicit "Approve".`;
|
|
71
|
+
function spawnFrom(arg, ctl, ui, images, specialist, plan = false) {
|
|
72
|
+
const p = ctl.sessionProvider();
|
|
73
|
+
if (!p)
|
|
74
|
+
return ui.system(t('m.missingProvider'));
|
|
75
|
+
if (!p.apiKey)
|
|
76
|
+
return ui.system(t('m.missingKey', { name: p.name }));
|
|
77
|
+
if (!ctl.session.model && !p.defaultModel && !p.models[0])
|
|
78
|
+
return ui.system(t('m.missingModel', { name: p.name }));
|
|
79
|
+
// optional --model=xxx flag
|
|
80
|
+
let model;
|
|
81
|
+
let task = arg;
|
|
82
|
+
const mFlag = task.match(/\s--model=(\S+)/);
|
|
83
|
+
if (mFlag) {
|
|
84
|
+
model = mFlag[1];
|
|
85
|
+
task = task.replace(mFlag[0], '').trim();
|
|
86
|
+
}
|
|
87
|
+
// optional #skill tokens → force-load these skills at the start of the task
|
|
88
|
+
const forced = [];
|
|
89
|
+
const available = ctl.getSkills();
|
|
90
|
+
task = task
|
|
91
|
+
.replace(/(^|\s)#([\p{L}\p{N}_-]+)/gu, (full, pre, name) => {
|
|
92
|
+
const skill = available.find((s) => s.name === name.toLowerCase());
|
|
93
|
+
if (!skill)
|
|
94
|
+
return full; // unknown → leave the text as-is
|
|
95
|
+
forced.push(skill.name);
|
|
96
|
+
return pre;
|
|
97
|
+
})
|
|
98
|
+
.trim();
|
|
99
|
+
if (forced.length > 0) {
|
|
100
|
+
task += `\n\nMANDATORY: before anything else, call load_skill for: ${forced.map((n) => `"${n}"`).join(', ')} and follow those instructions.`;
|
|
101
|
+
}
|
|
102
|
+
// optional "Name:" prefix
|
|
103
|
+
const named = task.match(/^([\p{L}\p{N}_-]{1,16}):\s+(.+)$/su);
|
|
104
|
+
const finalTask = (named ? named[2] : task) + (plan ? PLAN_FIRST : '');
|
|
105
|
+
const agent = ctl.spawnAgent(finalTask, named ? named[1] : undefined, model, images, specialist);
|
|
106
|
+
if (!agent)
|
|
107
|
+
return ui.system(specialist ? t('m.noSpecialist', { name: specialist }) : t('m.spawnFail'));
|
|
108
|
+
ui.system(t('m.spawned', { name: agent.name, model: model ? ` (${model})` : '' }) +
|
|
109
|
+
(plan ? ' 📋plan' : '') +
|
|
110
|
+
(specialist ? ` 🎓${specialist}` : '') +
|
|
111
|
+
(forced.length > 0 ? ` 🧩${forced.join(',')}` : ''));
|
|
112
|
+
}
|
|
113
|
+
export function executeInput(raw, ctl, ui, images) {
|
|
114
|
+
const input = raw.trim();
|
|
115
|
+
if (!input)
|
|
116
|
+
return;
|
|
117
|
+
// "@Agent message" or "@all message" → live instruction
|
|
118
|
+
if (input.startsWith('@')) {
|
|
119
|
+
if (images?.length)
|
|
120
|
+
ui.system(t('m.imagesIgnored'));
|
|
121
|
+
const m = input.match(/^@(\S+)\s+(.+)$/s);
|
|
122
|
+
if (!m)
|
|
123
|
+
return ui.system(t('m.usageAt'));
|
|
124
|
+
const [, target, content] = m;
|
|
125
|
+
if (target.toLowerCase() === 'all') {
|
|
126
|
+
ctl.broadcast(content);
|
|
127
|
+
ui.system(t('m.broadcast'));
|
|
128
|
+
}
|
|
129
|
+
else if (ctl.sendToAgent(target, content)) {
|
|
130
|
+
ui.system(t('m.sent', { target }));
|
|
131
|
+
}
|
|
132
|
+
else {
|
|
133
|
+
ui.system(t('m.notFound', { target, list: agentList(ctl) }));
|
|
134
|
+
}
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
// Plain text → ALWAYS spawns agent N+1, even while others are working.
|
|
138
|
+
if (!input.startsWith('/')) {
|
|
139
|
+
spawnFrom(input, ctl, ui, images);
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
if (images?.length)
|
|
143
|
+
ui.system(t('m.imagesIgnored'));
|
|
144
|
+
const [cmd, ...rest] = input.split(/\s+/);
|
|
145
|
+
const arg = rest.join(' ').trim();
|
|
146
|
+
switch (cmd.toLowerCase()) {
|
|
147
|
+
case '/spawn': {
|
|
148
|
+
if (!arg)
|
|
149
|
+
return ui.system(t('m.usageSpawn'));
|
|
150
|
+
spawnFrom(arg, ctl, ui, images);
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
case '/plan': {
|
|
154
|
+
// Plan-first agent: presents its plan (ask_user) and waits for approval
|
|
155
|
+
// before touching any file.
|
|
156
|
+
if (!arg)
|
|
157
|
+
return ui.system(t('m.usagePlan'));
|
|
158
|
+
spawnFrom(arg, ctl, ui, images, undefined, true);
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
case '/issue': {
|
|
162
|
+
// Import a task from GitHub Issues (requires the gh CLI, authenticated).
|
|
163
|
+
const n = Number.parseInt(arg, 10);
|
|
164
|
+
if (!arg || Number.isNaN(n))
|
|
165
|
+
return ui.system(t('m.usageIssue'));
|
|
166
|
+
const issue = ctl.fetchIssue(n);
|
|
167
|
+
if ('error' in issue) {
|
|
168
|
+
return ui.system(issue.error === 'gh-missing' ? t('m.ghMissing') : t('m.issueFail', { msg: issue.error }));
|
|
169
|
+
}
|
|
170
|
+
const task = `GitHub issue #${issue.number}: ${issue.title}\n\n${issue.body || '(no description)'}\n\nResolve this issue.`;
|
|
171
|
+
const agent = ctl.spawnAgent(task);
|
|
172
|
+
if (!agent)
|
|
173
|
+
return ui.system(t('m.spawnFail'));
|
|
174
|
+
ui.system(t('m.issueSpawned', { n: String(issue.number), name: agent.name, title: issue.title.slice(0, 60) }));
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
case '/undo': {
|
|
178
|
+
// Revert the agent's LAST file change (blackboard checkpoint).
|
|
179
|
+
const who = arg || soloAgent(ctl);
|
|
180
|
+
if (!who)
|
|
181
|
+
return ui.system(t('m.usageUndo'));
|
|
182
|
+
const r = ctl.undoAgent(who);
|
|
183
|
+
if (r === null)
|
|
184
|
+
return ui.system(t('m.notFound', { target: who, list: agentList(ctl) }));
|
|
185
|
+
if (r === 'none')
|
|
186
|
+
return ui.system(t('m.undoNone', { name: who }));
|
|
187
|
+
ui.system(t('m.undone', { name: who, path: r.path }) + (r.conflict ? ' ' + t('m.undoConflict') : ''));
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
case '/commit': {
|
|
191
|
+
// Commit the files touched by one agent (or all) — staged by explicit path.
|
|
192
|
+
const [target0, ...msg] = rest;
|
|
193
|
+
const target = target0 || soloAgent(ctl);
|
|
194
|
+
if (!target)
|
|
195
|
+
return ui.system(t('m.usageCommit'));
|
|
196
|
+
const r = ctl.commitFor(target, msg.join(' ').trim() || undefined);
|
|
197
|
+
if (r.ok)
|
|
198
|
+
return ui.system(t('m.committed', { name: target, files: String(r.files) }));
|
|
199
|
+
if (r.reason === 'not-found')
|
|
200
|
+
return ui.system(t('m.notFound', { target, list: agentList(ctl) }));
|
|
201
|
+
if (r.reason === 'no-changes')
|
|
202
|
+
return ui.system(t('m.commitNone', { name: target }));
|
|
203
|
+
return ui.system(t('m.commitFail', { msg: r.detail ?? '' }));
|
|
204
|
+
}
|
|
205
|
+
case '/autocommit': {
|
|
206
|
+
if (arg !== 'on' && arg !== 'off')
|
|
207
|
+
return ui.system(t('m.usageAutocommit', { state: ctl.autoCommit ? 'on' : 'off' }));
|
|
208
|
+
ctl.autoCommit = arg === 'on';
|
|
209
|
+
ui.system(t('m.autocommit', { state: arg }));
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
case '/agents':
|
|
213
|
+
ui.setView('agents');
|
|
214
|
+
return;
|
|
215
|
+
case '/sessions':
|
|
216
|
+
ui.setView('sessions');
|
|
217
|
+
return;
|
|
218
|
+
case '/session': {
|
|
219
|
+
// No argument → show the saved-sessions list (same as /sessions),
|
|
220
|
+
// so the natural flow is: /session → pick → /session <n>.
|
|
221
|
+
if (!arg) {
|
|
222
|
+
ui.setView('sessions');
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
const sessions = Controller.listSessions(ctl.projectRoot);
|
|
226
|
+
if (sessions.length === 0)
|
|
227
|
+
return ui.system(t('m.usageSession'));
|
|
228
|
+
const idx = arg.toLowerCase() === 'latest' ? 0 : Number.parseInt(arg, 10) - 1;
|
|
229
|
+
const session = sessions[idx];
|
|
230
|
+
if (!session)
|
|
231
|
+
return ui.system(t('m.usageSession'));
|
|
232
|
+
ctl.loadSession(session.data);
|
|
233
|
+
ui.system(t('m.sessionLoaded', { date: new Date(session.data.savedAt).toLocaleString() }));
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
case '/restore': {
|
|
237
|
+
// Relaunch an agent from the restored session with its FULL conversation.
|
|
238
|
+
if (!arg)
|
|
239
|
+
return ui.system(t('m.usageRestore'));
|
|
240
|
+
if (!ctl.loadedSession)
|
|
241
|
+
return ui.system(t('m.usageSession'));
|
|
242
|
+
const res = ctl.respawnAgent(arg);
|
|
243
|
+
if (res === 'no-conversation')
|
|
244
|
+
return ui.system(t('m.noConversation', { name: arg }));
|
|
245
|
+
if (!res)
|
|
246
|
+
return ui.system(t('m.spawnFail'));
|
|
247
|
+
ui.system(t('m.restored', { name: res.name }));
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
case '/attach': {
|
|
251
|
+
// Multi-terminal: open (or toggle the auto-opening of) a dedicated
|
|
252
|
+
// terminal per agent, connected to this session.
|
|
253
|
+
const who = arg || soloAgent(ctl);
|
|
254
|
+
if (!who)
|
|
255
|
+
return ui.system(t('m.usageAttach', { state: ctl.autoAttach ? 'on' : 'off' }));
|
|
256
|
+
if (who === 'on' || who === 'off') {
|
|
257
|
+
ctl.autoAttach = who === 'on';
|
|
258
|
+
ui.system(t('m.attachAuto', { state: who }));
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
const a = ctl.board.getAgentByName(who);
|
|
262
|
+
if (!a)
|
|
263
|
+
return ui.system(t('m.notFound', { target: who, list: agentList(ctl) }));
|
|
264
|
+
if (!ctl.attachEnabled)
|
|
265
|
+
return ui.system(t('m.attachManual', { cmd: `parallel attach ${a.alias}` }));
|
|
266
|
+
const r = ctl.openTerminal(a.alias);
|
|
267
|
+
ui.system(r === 'opened'
|
|
268
|
+
? t('m.attachOpened', { name: a.name })
|
|
269
|
+
: t('m.attachManual', { cmd: `parallel attach ${a.alias}` }));
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
case '/focus': {
|
|
273
|
+
const who = arg || soloAgent(ctl);
|
|
274
|
+
if (!who)
|
|
275
|
+
return ui.system(t('m.usageFocus'));
|
|
276
|
+
if (!ui.setFocus)
|
|
277
|
+
return;
|
|
278
|
+
if (who.toLowerCase() === 'off') {
|
|
279
|
+
ui.setFocus(null);
|
|
280
|
+
ui.system(t('m.focusOff'));
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
const a = ctl.board.getAgentByName(who);
|
|
284
|
+
if (!a)
|
|
285
|
+
return ui.system(t('m.notFound', { target: who, list: agentList(ctl) }));
|
|
286
|
+
ui.setFocus(a.name);
|
|
287
|
+
ui.system(t('m.focusOn', { name: a.name }));
|
|
288
|
+
return;
|
|
289
|
+
}
|
|
290
|
+
case '/doctor': {
|
|
291
|
+
const p = ctl.sessionProvider();
|
|
292
|
+
if (!p)
|
|
293
|
+
return ui.system(t('m.missingProvider'));
|
|
294
|
+
if (!p.apiKey)
|
|
295
|
+
return ui.system(t('m.missingKey', { name: p.name }));
|
|
296
|
+
if (!ctl.session.model && !p.defaultModel && !p.models[0])
|
|
297
|
+
return ui.system(t('m.missingModel', { name: p.name }));
|
|
298
|
+
ui.system(t('m.doctorOk', { pm: `${p.name}:${ctl.session.model || p.defaultModel || p.models[0]}` }));
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
301
|
+
case '/cost':
|
|
302
|
+
ui.setView('cost');
|
|
303
|
+
return;
|
|
304
|
+
case '/skills':
|
|
305
|
+
ui.setView('skills');
|
|
306
|
+
return;
|
|
307
|
+
case '/specialists':
|
|
308
|
+
ui.setView('specialists');
|
|
309
|
+
return;
|
|
310
|
+
case '/skill': {
|
|
311
|
+
// /skill new <name> [global] → create a template file to edit
|
|
312
|
+
const m = arg.match(/^new\s+([\p{L}\p{N}_-]+)(\s+global)?$/iu);
|
|
313
|
+
if (!m)
|
|
314
|
+
return ui.system(t('m.usageSkill'));
|
|
315
|
+
try {
|
|
316
|
+
const file = createSkillTemplate(m[1], '', m[2] ? 'global' : 'project', ctl.projectRoot);
|
|
317
|
+
ui.system(t('m.skillCreated', { file }));
|
|
318
|
+
}
|
|
319
|
+
catch (e) {
|
|
320
|
+
ui.system(t('m.alreadyExists', { msg: e?.message ?? '' }));
|
|
321
|
+
}
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
case '/specialist': {
|
|
325
|
+
if (!arg)
|
|
326
|
+
return ui.system(t('m.usageSpecialist'));
|
|
327
|
+
// /specialist new <name> [global] → create a template file
|
|
328
|
+
const created = arg.match(/^new\s+([\p{L}\p{N}_-]+)(\s+global)?$/iu);
|
|
329
|
+
if (created) {
|
|
330
|
+
try {
|
|
331
|
+
const file = createSpecialistTemplate(created[1], '', created[2] ? 'global' : 'project', ctl.projectRoot);
|
|
332
|
+
ui.system(t('m.specCreated', { file }));
|
|
333
|
+
}
|
|
334
|
+
catch (e) {
|
|
335
|
+
ui.system(t('m.alreadyExists', { msg: e?.message ?? '' }));
|
|
336
|
+
}
|
|
337
|
+
return;
|
|
338
|
+
}
|
|
339
|
+
// /specialist <name> [Name:] <task> → spawn an agent with this persona
|
|
340
|
+
const m = arg.match(/^([\p{L}\p{N}_-]+)\s+(.+)$/su);
|
|
341
|
+
if (!m)
|
|
342
|
+
return ui.system(t('m.usageSpecialist'));
|
|
343
|
+
const exists = ctl.getSpecialists().some((s) => s.name === m[1].toLowerCase());
|
|
344
|
+
if (!exists) {
|
|
345
|
+
const list = ctl.getSpecialists().map((s) => s.name).join(', ') || t('m.none');
|
|
346
|
+
return ui.system(t('m.noSpecialist', { name: m[1] }) + ` (${list})`);
|
|
347
|
+
}
|
|
348
|
+
spawnFrom(m[2], ctl, ui, images, m[1].toLowerCase());
|
|
349
|
+
return;
|
|
350
|
+
}
|
|
351
|
+
case '/board':
|
|
352
|
+
ui.setView('board');
|
|
353
|
+
return;
|
|
354
|
+
case '/notes':
|
|
355
|
+
ui.setView('notes');
|
|
356
|
+
return;
|
|
357
|
+
case '/diff':
|
|
358
|
+
ui.setView('diff');
|
|
359
|
+
return;
|
|
360
|
+
case '/send': {
|
|
361
|
+
const [target, ...msg] = rest;
|
|
362
|
+
const content = msg.join(' ').trim();
|
|
363
|
+
if (!target || !content)
|
|
364
|
+
return ui.system(t('m.usageSend'));
|
|
365
|
+
executeInput(`@${target} ${content}`, ctl, ui);
|
|
366
|
+
return;
|
|
367
|
+
}
|
|
368
|
+
case '/pause': {
|
|
369
|
+
const who = arg || soloAgent(ctl);
|
|
370
|
+
if (!who)
|
|
371
|
+
return ui.system(t('m.usagePause'));
|
|
372
|
+
if (who === 'all') {
|
|
373
|
+
for (const a of ctl.board.agents.values())
|
|
374
|
+
ctl.pauseAgent(a.name);
|
|
375
|
+
ui.system(t('m.allPaused'));
|
|
376
|
+
}
|
|
377
|
+
else {
|
|
378
|
+
ui.system(ctl.pauseAgent(who) ? t('m.paused', { name: who }) : t('m.notFound', { target: who, list: agentList(ctl) }));
|
|
379
|
+
}
|
|
380
|
+
return;
|
|
381
|
+
}
|
|
382
|
+
case '/resume': {
|
|
383
|
+
const who = arg || soloAgent(ctl);
|
|
384
|
+
if (!who)
|
|
385
|
+
return ui.system(t('m.usageResume'));
|
|
386
|
+
if (who === 'all') {
|
|
387
|
+
for (const a of ctl.board.agents.values())
|
|
388
|
+
ctl.resumeAgent(a.name);
|
|
389
|
+
ui.system(t('m.allResumed'));
|
|
390
|
+
}
|
|
391
|
+
else {
|
|
392
|
+
ui.system(ctl.resumeAgent(who) ? t('m.resumed', { name: who }) : t('m.notFound', { target: who, list: agentList(ctl) }));
|
|
393
|
+
}
|
|
394
|
+
return;
|
|
395
|
+
}
|
|
396
|
+
case '/stop': {
|
|
397
|
+
const who = arg || soloAgent(ctl);
|
|
398
|
+
if (!who)
|
|
399
|
+
return ui.system(t('m.usageStop'));
|
|
400
|
+
if (who === 'all') {
|
|
401
|
+
ctl.stopAll();
|
|
402
|
+
ui.system(t('m.allStopped'));
|
|
403
|
+
}
|
|
404
|
+
else {
|
|
405
|
+
ui.system(ctl.stopAgent(who) ? t('m.stopped', { name: who }) : t('m.notFound', { target: who, list: agentList(ctl) }));
|
|
406
|
+
}
|
|
407
|
+
return;
|
|
408
|
+
}
|
|
409
|
+
// SESSION-only: changes the model for this session, never persisted.
|
|
410
|
+
case '/model': {
|
|
411
|
+
if (!arg) {
|
|
412
|
+
const p = ctl.sessionProvider();
|
|
413
|
+
return ui.system(t('m.model', { pm: p ? `${p.name}:${ctl.session.model}` : '—' }));
|
|
414
|
+
}
|
|
415
|
+
const r = ctl.setSessionModel(arg);
|
|
416
|
+
if (!r) {
|
|
417
|
+
const provName = arg.includes(':') ? arg.split(':')[0] : arg;
|
|
418
|
+
return ui.system(t('m.noProvider', { name: provName, list: ctl.config.providers.map((p) => p.name).join(', ') || t('m.none') }));
|
|
419
|
+
}
|
|
420
|
+
ui.system(t('m.modelSet', { pm: `${r.provider}:${r.model}` }));
|
|
421
|
+
return;
|
|
422
|
+
}
|
|
423
|
+
case '/settings':
|
|
424
|
+
ui.setView('settings');
|
|
425
|
+
return;
|
|
426
|
+
case '/settings-session':
|
|
427
|
+
case '/ssettings':
|
|
428
|
+
ui.setView('settings-session');
|
|
429
|
+
return;
|
|
430
|
+
// SESSION-only approvals & sound (global defaults editable in /settings).
|
|
431
|
+
case '/approvals': {
|
|
432
|
+
if (arg !== 'ask' && arg !== 'auto')
|
|
433
|
+
return ui.system(t('m.usageApprovals'));
|
|
434
|
+
ctl.setSessionApprovalMode(arg);
|
|
435
|
+
ui.system(t('m.approvals', { mode: arg }) + (arg === 'auto' ? t('m.approvalsWarn') : ''));
|
|
436
|
+
return;
|
|
437
|
+
}
|
|
438
|
+
case '/sound': {
|
|
439
|
+
if (arg !== 'on' && arg !== 'off')
|
|
440
|
+
return ui.system(t('m.usageSound', { state: ctl.session.soundEnabled ? 'on' : 'off' }));
|
|
441
|
+
ctl.setSessionSound(arg === 'on');
|
|
442
|
+
ui.system(t('m.sound', { state: arg }));
|
|
443
|
+
return;
|
|
444
|
+
}
|
|
445
|
+
case '/save': {
|
|
446
|
+
const file = ctl.saveSession(arg || undefined);
|
|
447
|
+
ui.system(file ? (arg ? t('m.savedAs', { name: arg }) : t('m.saved')) : t('m.nothing'));
|
|
448
|
+
return;
|
|
449
|
+
}
|
|
450
|
+
case '/key': {
|
|
451
|
+
if (!arg)
|
|
452
|
+
return ui.system(t('m.usageKey'));
|
|
453
|
+
const ok = ctl.setApiKey(arg);
|
|
454
|
+
ui.system(ok ? t('m.keySaved', { name: ctl.sessionProvider()?.name ?? '?' }) : t('m.spawnFail'));
|
|
455
|
+
return;
|
|
456
|
+
}
|
|
457
|
+
case '/clear': {
|
|
458
|
+
for (const [id, a] of [...ctl.board.agents.entries()]) {
|
|
459
|
+
if (['done', 'stopped', 'error'].includes(a.state)) {
|
|
460
|
+
ctl.board.agents.delete(id);
|
|
461
|
+
ctl.agents.delete(id);
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
ctl.emit('update');
|
|
465
|
+
ui.system(t('m.cleared'));
|
|
466
|
+
return;
|
|
467
|
+
}
|
|
468
|
+
case '/help':
|
|
469
|
+
ui.setView('help');
|
|
470
|
+
return;
|
|
471
|
+
case '/quit':
|
|
472
|
+
case '/exit':
|
|
473
|
+
ctl.saveSession();
|
|
474
|
+
ctl.stopAll();
|
|
475
|
+
ui.exit();
|
|
476
|
+
return;
|
|
477
|
+
default:
|
|
478
|
+
ui.system(t('m.unknown', { cmd }));
|
|
479
|
+
}
|
|
480
|
+
}
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
let configHomeOverride;
|
|
5
|
+
export function setConfigHome(dir) {
|
|
6
|
+
configHomeOverride = path.resolve(dir.replace(/^~(?=$|\/)/, os.homedir()));
|
|
7
|
+
}
|
|
8
|
+
export function configDir() {
|
|
9
|
+
return configHomeOverride ?? path.join(os.homedir(), '.parallel');
|
|
10
|
+
}
|
|
11
|
+
export function configFile() {
|
|
12
|
+
return path.join(configDir(), 'config.json');
|
|
13
|
+
}
|
|
14
|
+
export const DEEPSEEK_PROVIDER = {
|
|
15
|
+
name: 'DeepSeek',
|
|
16
|
+
baseUrl: 'https://api.deepseek.com',
|
|
17
|
+
apiKey: '',
|
|
18
|
+
models: ['deepseek-v4-flash', 'deepseek-v4-pro', 'deepseek-chat', 'deepseek-reasoner'],
|
|
19
|
+
defaultModel: 'deepseek-v4-flash',
|
|
20
|
+
};
|
|
21
|
+
export const PROVIDER_PRESETS = [
|
|
22
|
+
{
|
|
23
|
+
name: 'OpenAI',
|
|
24
|
+
baseUrl: 'https://api.openai.com/v1',
|
|
25
|
+
apiKey: '',
|
|
26
|
+
models: ['gpt-4o', 'gpt-4o-mini', 'o4-mini', 'gpt-4.1', 'gpt-4.1-mini'],
|
|
27
|
+
defaultModel: 'gpt-4o',
|
|
28
|
+
},
|
|
29
|
+
DEEPSEEK_PROVIDER,
|
|
30
|
+
{
|
|
31
|
+
name: 'Anthropic',
|
|
32
|
+
baseUrl: 'https://api.anthropic.com/v1/',
|
|
33
|
+
apiKey: '',
|
|
34
|
+
models: ['claude-sonnet-4-6', 'claude-opus-4-8'],
|
|
35
|
+
defaultModel: 'claude-sonnet-4-6',
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
name: 'OpenRouter',
|
|
39
|
+
baseUrl: 'https://openrouter.ai/api/v1',
|
|
40
|
+
apiKey: '',
|
|
41
|
+
models: ['openai/gpt-4o', 'anthropic/claude-sonnet-4', 'deepseek/deepseek-chat', 'google/gemini-pro'],
|
|
42
|
+
defaultModel: 'openai/gpt-4o',
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
name: 'Gemini',
|
|
46
|
+
baseUrl: 'https://generativelanguage.googleapis.com/v1beta/openai/',
|
|
47
|
+
apiKey: '',
|
|
48
|
+
models: ['gemini-3.5-flash', 'gemini-3.5-pro', 'gemini-2.5-pro'],
|
|
49
|
+
defaultModel: 'gemini-3.5-flash',
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
name: 'Mistral',
|
|
53
|
+
baseUrl: 'https://api.mistral.ai/v1',
|
|
54
|
+
apiKey: '',
|
|
55
|
+
models: ['mistral-large-latest', 'mistral-small-latest', 'codestral-latest'],
|
|
56
|
+
defaultModel: 'mistral-large-latest',
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
name: 'Groq',
|
|
60
|
+
baseUrl: 'https://api.groq.com/openai/v1',
|
|
61
|
+
apiKey: '',
|
|
62
|
+
models: ['llama-3.3-70b-versatile', 'openai/gpt-oss-120b'],
|
|
63
|
+
defaultModel: 'llama-3.3-70b-versatile',
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
name: 'Together',
|
|
67
|
+
baseUrl: 'https://api.together.ai/v1',
|
|
68
|
+
apiKey: '',
|
|
69
|
+
models: ['openai/gpt-oss-120b', 'openai/gpt-oss-20b'],
|
|
70
|
+
defaultModel: 'openai/gpt-oss-120b',
|
|
71
|
+
},
|
|
72
|
+
];
|
|
73
|
+
export const DEFAULTS = {
|
|
74
|
+
providers: [],
|
|
75
|
+
defaultProvider: '',
|
|
76
|
+
approvalMode: 'ask',
|
|
77
|
+
maxStepsPerAgent: 60,
|
|
78
|
+
soundEnabled: true,
|
|
79
|
+
recentFolders: [],
|
|
80
|
+
};
|
|
81
|
+
export function getProvider(cfg, name) {
|
|
82
|
+
const n = (name ?? cfg.defaultProvider).toLowerCase();
|
|
83
|
+
return cfg.providers.find((p) => p.name.toLowerCase() === n) ?? (name ? undefined : cfg.providers[0]);
|
|
84
|
+
}
|
|
85
|
+
export function upsertProvider(cfg, p) {
|
|
86
|
+
const i = cfg.providers.findIndex((x) => x.name.toLowerCase() === p.name.toLowerCase());
|
|
87
|
+
if (i >= 0)
|
|
88
|
+
cfg.providers[i] = p;
|
|
89
|
+
else
|
|
90
|
+
cfg.providers.push(p);
|
|
91
|
+
if (!cfg.defaultProvider)
|
|
92
|
+
cfg.defaultProvider = p.name;
|
|
93
|
+
saveConfig(cfg);
|
|
94
|
+
}
|
|
95
|
+
/** Migrate the pre-provider config shape {apiKey, baseUrl, model} → providers[]. */
|
|
96
|
+
function migrate(raw, cfg) {
|
|
97
|
+
if (Array.isArray(raw.providers))
|
|
98
|
+
return; // already new shape
|
|
99
|
+
const apiKey = typeof raw.apiKey === 'string' ? raw.apiKey : '';
|
|
100
|
+
const baseUrl = typeof raw.baseUrl === 'string' ? raw.baseUrl : DEEPSEEK_PROVIDER.baseUrl;
|
|
101
|
+
const model = typeof raw.model === 'string' ? raw.model : DEEPSEEK_PROVIDER.defaultModel;
|
|
102
|
+
if (!apiKey && baseUrl === DEEPSEEK_PROVIDER.baseUrl)
|
|
103
|
+
return; // nothing worth migrating
|
|
104
|
+
const isDeepseek = baseUrl.includes('deepseek');
|
|
105
|
+
const p = isDeepseek
|
|
106
|
+
? { ...DEEPSEEK_PROVIDER, apiKey, defaultModel: model, models: [...new Set([...DEEPSEEK_PROVIDER.models, model])] }
|
|
107
|
+
: { name: 'Custom', baseUrl, apiKey, models: [model], defaultModel: model };
|
|
108
|
+
cfg.providers = [p];
|
|
109
|
+
cfg.defaultProvider = p.name;
|
|
110
|
+
}
|
|
111
|
+
export function loadConfig() {
|
|
112
|
+
let cfg = { ...DEFAULTS, providers: [] };
|
|
113
|
+
try {
|
|
114
|
+
const file = configFile();
|
|
115
|
+
if (fs.existsSync(file)) {
|
|
116
|
+
const raw = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
117
|
+
cfg = { ...cfg, ...raw };
|
|
118
|
+
if (!Array.isArray(cfg.providers))
|
|
119
|
+
cfg.providers = [];
|
|
120
|
+
migrate(raw, cfg);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
catch {
|
|
124
|
+
// ignore corrupted config
|
|
125
|
+
}
|
|
126
|
+
// Env vars: ensure a DeepSeek provider exists / override the default provider.
|
|
127
|
+
const envKey = process.env.PARALLEL_API_KEY || process.env.DEEPSEEK_API_KEY;
|
|
128
|
+
if (envKey && cfg.providers.length === 0) {
|
|
129
|
+
cfg.providers = [{ ...DEEPSEEK_PROVIDER, apiKey: envKey }];
|
|
130
|
+
cfg.defaultProvider = DEEPSEEK_PROVIDER.name;
|
|
131
|
+
}
|
|
132
|
+
else if (envKey) {
|
|
133
|
+
const p = getProvider(cfg);
|
|
134
|
+
if (p)
|
|
135
|
+
p.apiKey = envKey;
|
|
136
|
+
}
|
|
137
|
+
const p = getProvider(cfg);
|
|
138
|
+
if (p) {
|
|
139
|
+
if (process.env.PARALLEL_BASE_URL)
|
|
140
|
+
p.baseUrl = process.env.PARALLEL_BASE_URL;
|
|
141
|
+
if (process.env.PARALLEL_MODEL) {
|
|
142
|
+
p.defaultModel = process.env.PARALLEL_MODEL;
|
|
143
|
+
if (!p.models.includes(p.defaultModel))
|
|
144
|
+
p.models.push(p.defaultModel);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
if (!Array.isArray(cfg.recentFolders))
|
|
148
|
+
cfg.recentFolders = [];
|
|
149
|
+
return cfg;
|
|
150
|
+
}
|
|
151
|
+
export function saveConfig(cfg) {
|
|
152
|
+
try {
|
|
153
|
+
fs.mkdirSync(configDir(), { recursive: true });
|
|
154
|
+
fs.writeFileSync(configFile(), JSON.stringify(cfg, null, 2));
|
|
155
|
+
}
|
|
156
|
+
catch {
|
|
157
|
+
// best effort
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
export function rememberFolder(cfg, folder) {
|
|
161
|
+
cfg.recentFolders = [folder, ...cfg.recentFolders.filter((f) => f !== folder)].slice(0, 8);
|
|
162
|
+
saveConfig(cfg);
|
|
163
|
+
}
|