@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.
@@ -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
- constructor(board, agentId, agentName, projectRoot, requestApproval, requestQuestion, skills) {
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 abs = path.resolve(this.projectRoot, rel);
246
- if (!abs.startsWith(path.resolve(this.projectRoot))) {
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 answer = await this.requestQuestion(this.agentId, question, options, recommended);
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
- return `The user answered: "${answer}". Act on this choice now (${3 - this.questionsAsked} question(s) left for this task).`;
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
- if (!ctl.session.model && !p.defaultModel && !p.models[0])
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 target = target0 || soloAgent(ctl);
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, msg.join(' ').trim() || undefined);
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 = arg.toLowerCase() === 'latest' ? 0 : Number.parseInt(arg, 10) - 1;
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
- const p = ctl.sessionProvider();
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);