@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
|
@@ -0,0 +1,703 @@
|
|
|
1
|
+
import { EventEmitter } from 'node:events';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { exec, execFileSync, spawn } from 'node:child_process';
|
|
5
|
+
import { Blackboard } from './coordination/blackboard.js';
|
|
6
|
+
import { LLMClient } from './llm/client.js';
|
|
7
|
+
import { Agent } from './agents/agent.js';
|
|
8
|
+
import { saveConfig, getProvider, upsertProvider } from './config.js';
|
|
9
|
+
import { priceFor, fmtCost } from './pricing.js';
|
|
10
|
+
import { loadSkills, loadSpecialists } from './skills.js';
|
|
11
|
+
import { t } from './i18n.js';
|
|
12
|
+
const AGENT_COLORS = ['cyan', 'magenta', 'yellow', 'green', 'blue', 'redBright', 'cyanBright', 'magentaBright'];
|
|
13
|
+
/**
|
|
14
|
+
* The Controller glues everything together: it owns the blackboard, the LLM
|
|
15
|
+
* clients, the live agents and the approval queue. The UI talks only to it.
|
|
16
|
+
*
|
|
17
|
+
* Paradigm: there is NO central orchestrator. The user launches agent N+1
|
|
18
|
+
* whenever they want, while agent N is still working. Agents coordinate
|
|
19
|
+
* between themselves through the blackboard (live statuses + diff feed + notes).
|
|
20
|
+
*
|
|
21
|
+
* Settings model:
|
|
22
|
+
* - `config` = GLOBAL settings, persisted in ~/.parallel/config.json (/settings)
|
|
23
|
+
* - `session` = SESSION settings, initialized from globals, never persisted (/settings-session, /model)
|
|
24
|
+
*/
|
|
25
|
+
export class Controller extends EventEmitter {
|
|
26
|
+
config;
|
|
27
|
+
projectRoot;
|
|
28
|
+
board;
|
|
29
|
+
agents = new Map();
|
|
30
|
+
approvals = [];
|
|
31
|
+
questions = [];
|
|
32
|
+
session;
|
|
33
|
+
agentSeq = 0;
|
|
34
|
+
approvalSeq = 0;
|
|
35
|
+
questionSeq = 0;
|
|
36
|
+
sessionAllowedCommands = new Set();
|
|
37
|
+
llmCache = new Map();
|
|
38
|
+
/** Stable per-run stamp: the session file is OVERWRITTEN (autosave), not duplicated. */
|
|
39
|
+
sessionStamp = new Date().toISOString().replace(/[:.]/g, '-');
|
|
40
|
+
/** Optional user-given session name (/save <name>). */
|
|
41
|
+
sessionName;
|
|
42
|
+
/** Conversation JSONL path per agent id — what makes /restore possible. */
|
|
43
|
+
conversationFiles = new Map();
|
|
44
|
+
/** The session restored at startup (source of /restore conversations). */
|
|
45
|
+
loadedSession = null;
|
|
46
|
+
constructor(config, projectRoot) {
|
|
47
|
+
super();
|
|
48
|
+
this.config = config;
|
|
49
|
+
this.projectRoot = projectRoot;
|
|
50
|
+
this.board = new Blackboard(projectRoot);
|
|
51
|
+
const p = getProvider(config);
|
|
52
|
+
this.session = {
|
|
53
|
+
providerName: p?.name ?? '',
|
|
54
|
+
model: p?.defaultModel || p?.models[0] || '',
|
|
55
|
+
approvalMode: config.approvalMode,
|
|
56
|
+
soundEnabled: config.soundEnabled,
|
|
57
|
+
};
|
|
58
|
+
this.board.on('update', () => this.emit('update'));
|
|
59
|
+
this.board.on('agent-event', (ev) => this.onAgentEvent(ev));
|
|
60
|
+
// Only the USER interrupts an agent's in-flight model call (steering).
|
|
61
|
+
// Agent→agent notes never cut each other off: they are injected at the
|
|
62
|
+
// recipient's NEXT step, together with the live snapshot + diffs — agents
|
|
63
|
+
// adapt at action boundaries, they don't interrupt one another.
|
|
64
|
+
this.board.on('note', (note) => {
|
|
65
|
+
if (note.to === 'all' || note.to === 'user')
|
|
66
|
+
return;
|
|
67
|
+
if (note.from !== 'user')
|
|
68
|
+
return;
|
|
69
|
+
const target = this.findAgent(note.to);
|
|
70
|
+
if (target)
|
|
71
|
+
target.nudge();
|
|
72
|
+
});
|
|
73
|
+
// Autosave: the session (+ conversations, written live by agents) survives a crash.
|
|
74
|
+
const autosave = setInterval(() => this.saveSession(), 30_000);
|
|
75
|
+
autosave.unref();
|
|
76
|
+
// User hooks: every agent write schedules the afterWrite command (debounced).
|
|
77
|
+
this.board.on('change', () => this.scheduleAfterWriteHook());
|
|
78
|
+
}
|
|
79
|
+
// ---------- providers / models ----------
|
|
80
|
+
/** Provider used by the current session (falls back to the global default). */
|
|
81
|
+
sessionProvider() {
|
|
82
|
+
return getProvider(this.config, this.session.providerName || undefined);
|
|
83
|
+
}
|
|
84
|
+
/** Resolve "model" or "provider:model" against the configured providers. */
|
|
85
|
+
resolveModel(spec) {
|
|
86
|
+
const m = spec.match(/^([^:]+):(.+)$/);
|
|
87
|
+
if (m) {
|
|
88
|
+
const provider = getProvider(this.config, m[1].trim());
|
|
89
|
+
return provider ? { provider, model: m[2].trim() } : null;
|
|
90
|
+
}
|
|
91
|
+
// bare model name: current session provider first, then any provider listing it
|
|
92
|
+
const cur = this.sessionProvider();
|
|
93
|
+
if (cur)
|
|
94
|
+
return { provider: cur, model: spec.trim() };
|
|
95
|
+
const any = this.config.providers.find((p) => p.models.includes(spec.trim()));
|
|
96
|
+
return any ? { provider: any, model: spec.trim() } : null;
|
|
97
|
+
}
|
|
98
|
+
llmFor(provider, model) {
|
|
99
|
+
const key = JSON.stringify([provider.name, provider.baseUrl, provider.apiKey, model]);
|
|
100
|
+
let c = this.llmCache.get(key);
|
|
101
|
+
if (!c) {
|
|
102
|
+
c = new LLMClient(provider.apiKey, provider.baseUrl, model);
|
|
103
|
+
this.llmCache.set(key, c);
|
|
104
|
+
}
|
|
105
|
+
return c;
|
|
106
|
+
}
|
|
107
|
+
// ---------- sound cues (terminal bell) ----------
|
|
108
|
+
onAgentEvent(ev) {
|
|
109
|
+
if (ev.type === 'conflict' && ev.path) {
|
|
110
|
+
// 3+ co-edit collisions on the same file: escalate to the user.
|
|
111
|
+
this.board.addNote('system', 'user', t('m.conflict', { path: ev.path }));
|
|
112
|
+
if (this.session.soundEnabled)
|
|
113
|
+
this.bell(2);
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
// /autocommit on: each finished agent commits its own files right away.
|
|
117
|
+
if (ev.type === 'state' && ev.state === 'done' && this.autoCommit && ev.id) {
|
|
118
|
+
const info = this.board.agents.get(ev.id);
|
|
119
|
+
if (info) {
|
|
120
|
+
const r = this.commitFor(info.name);
|
|
121
|
+
this.board.log('', 'system', r.ok
|
|
122
|
+
? t('m.committed', { name: info.name, files: String(r.files) })
|
|
123
|
+
: r.reason === 'no-changes'
|
|
124
|
+
? t('m.commitNone', { name: info.name })
|
|
125
|
+
: t('m.commitFail', { msg: r.detail ?? r.reason }));
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
if (!this.session.soundEnabled)
|
|
129
|
+
return;
|
|
130
|
+
if (ev.type === 'spawn')
|
|
131
|
+
this.bell(1);
|
|
132
|
+
if (ev.type === 'state') {
|
|
133
|
+
if (ev.state === 'waiting')
|
|
134
|
+
this.bell(2); // needs your approval
|
|
135
|
+
else if (ev.state === 'done')
|
|
136
|
+
this.bell(1);
|
|
137
|
+
else if (ev.state === 'error' || ev.state === 'stopped')
|
|
138
|
+
this.bell(1);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
bell(times) {
|
|
142
|
+
for (let i = 0; i < times; i++) {
|
|
143
|
+
setTimeout(() => process.stdout.write(''), i * 250);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
// ---------- approvals ----------
|
|
147
|
+
requestApproval = (agentId, command) => {
|
|
148
|
+
if (this.session.approvalMode === 'auto')
|
|
149
|
+
return Promise.resolve(true);
|
|
150
|
+
const base = command.trim().split(/\s+/)[0];
|
|
151
|
+
if (this.sessionAllowedCommands.has(base))
|
|
152
|
+
return Promise.resolve(true);
|
|
153
|
+
return new Promise((resolve) => {
|
|
154
|
+
const agent = this.board.agents.get(agentId);
|
|
155
|
+
this.approvals.push({
|
|
156
|
+
id: ++this.approvalSeq,
|
|
157
|
+
agentId,
|
|
158
|
+
agentName: agent?.name ?? agentId,
|
|
159
|
+
command,
|
|
160
|
+
resolve,
|
|
161
|
+
});
|
|
162
|
+
this.emit('update');
|
|
163
|
+
});
|
|
164
|
+
};
|
|
165
|
+
answerApproval(id, approved, always = false) {
|
|
166
|
+
const idx = this.approvals.findIndex((a) => a.id === id);
|
|
167
|
+
if (idx === -1)
|
|
168
|
+
return;
|
|
169
|
+
const [req] = this.approvals.splice(idx, 1);
|
|
170
|
+
if (approved && always) {
|
|
171
|
+
this.sessionAllowedCommands.add(req.command.trim().split(/\s+/)[0]);
|
|
172
|
+
}
|
|
173
|
+
req.resolve(approved);
|
|
174
|
+
this.emit('update');
|
|
175
|
+
}
|
|
176
|
+
// ---------- agent questions (ask_user, 30s auto-run countdown in the UI) ----------
|
|
177
|
+
requestQuestion = (agentId, question, options, recommended) => {
|
|
178
|
+
return new Promise((resolve) => {
|
|
179
|
+
const agent = this.board.agents.get(agentId);
|
|
180
|
+
this.questions.push({
|
|
181
|
+
id: ++this.questionSeq,
|
|
182
|
+
agentId,
|
|
183
|
+
agentName: agent?.name ?? agentId,
|
|
184
|
+
question,
|
|
185
|
+
options,
|
|
186
|
+
recommended,
|
|
187
|
+
resolve,
|
|
188
|
+
});
|
|
189
|
+
if (this.session.soundEnabled)
|
|
190
|
+
this.bell(2); // the user is probably doing something else
|
|
191
|
+
this.emit('update');
|
|
192
|
+
});
|
|
193
|
+
};
|
|
194
|
+
/** Answer a pending agent question (by the user, or by the auto-run countdown). */
|
|
195
|
+
answerQuestion(id, answer, auto = false) {
|
|
196
|
+
const idx = this.questions.findIndex((q) => q.id === id);
|
|
197
|
+
if (idx === -1)
|
|
198
|
+
return;
|
|
199
|
+
const [q] = this.questions.splice(idx, 1);
|
|
200
|
+
this.board.log('', 'system', auto ? t('m.qAuto', { name: q.agentName, answer }) : t('m.qAnswered', { name: q.agentName, answer }));
|
|
201
|
+
q.resolve(answer);
|
|
202
|
+
this.emit('update');
|
|
203
|
+
}
|
|
204
|
+
// ---------- agents ----------
|
|
205
|
+
/** Reload skills/specialists from disk (cheap — called on spawn and on demand). */
|
|
206
|
+
getSkills() {
|
|
207
|
+
return loadSkills(this.projectRoot);
|
|
208
|
+
}
|
|
209
|
+
getSpecialists() {
|
|
210
|
+
return loadSpecialists(this.projectRoot);
|
|
211
|
+
}
|
|
212
|
+
/** Shared project memory (.parallel/memory.md) — injected into every agent's system prompt. */
|
|
213
|
+
projectMemory() {
|
|
214
|
+
try {
|
|
215
|
+
const f = path.join(this.projectRoot, '.parallel', 'memory.md');
|
|
216
|
+
if (!fs.existsSync(f))
|
|
217
|
+
return undefined;
|
|
218
|
+
const lines = fs.readFileSync(f, 'utf8').trim().split('\n');
|
|
219
|
+
return lines.slice(-50).join('\n') || undefined;
|
|
220
|
+
}
|
|
221
|
+
catch {
|
|
222
|
+
return undefined;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
/** Launch agent N+1 — works at any time, even while others are running. */
|
|
226
|
+
spawnAgent(task, name, modelSpec, images, specialistName, initialHistory) {
|
|
227
|
+
// Specialist persona: role appended to the system prompt, may pin a model.
|
|
228
|
+
let specialist;
|
|
229
|
+
if (specialistName) {
|
|
230
|
+
specialist = this.getSpecialists().find((s) => s.name === specialistName.toLowerCase());
|
|
231
|
+
if (!specialist)
|
|
232
|
+
return null;
|
|
233
|
+
}
|
|
234
|
+
const spec = modelSpec || specialist?.model;
|
|
235
|
+
const resolved = spec
|
|
236
|
+
? this.resolveModel(spec)
|
|
237
|
+
: (() => {
|
|
238
|
+
const p = this.sessionProvider();
|
|
239
|
+
const model = this.session.model || p?.defaultModel || p?.models[0] || '';
|
|
240
|
+
return p && model ? { provider: p, model } : null;
|
|
241
|
+
})();
|
|
242
|
+
if (!resolved)
|
|
243
|
+
return null;
|
|
244
|
+
const id = `agent-${++this.agentSeq}`;
|
|
245
|
+
// Short stable handle: a1, a2, … — never runs out, fast to type (@a3 fix the tests).
|
|
246
|
+
// A custom name keeps its alias, so the agent stays addressable both ways.
|
|
247
|
+
const alias = `a${this.agentSeq}`;
|
|
248
|
+
const agentName = name?.trim() || alias;
|
|
249
|
+
const color = AGENT_COLORS[(this.agentSeq - 1) % AGENT_COLORS.length];
|
|
250
|
+
// Conversation file (JSONL, appended live) — enables /restore after a save.
|
|
251
|
+
let historyFile;
|
|
252
|
+
try {
|
|
253
|
+
const convDir = path.join(this.sessionsDir(), 'conversations');
|
|
254
|
+
fs.mkdirSync(convDir, { recursive: true });
|
|
255
|
+
historyFile = path.join(convDir, `${this.sessionStamp}-${id}-${agentName.replace(/[^\w.-]+/g, '_')}.jsonl`);
|
|
256
|
+
}
|
|
257
|
+
catch {
|
|
258
|
+
historyFile = undefined;
|
|
259
|
+
}
|
|
260
|
+
const agent = new Agent({
|
|
261
|
+
id,
|
|
262
|
+
name: agentName,
|
|
263
|
+
alias,
|
|
264
|
+
color,
|
|
265
|
+
task,
|
|
266
|
+
model: resolved.model,
|
|
267
|
+
llm: this.llmFor(resolved.provider, resolved.model),
|
|
268
|
+
board: this.board,
|
|
269
|
+
projectRoot: this.projectRoot,
|
|
270
|
+
maxSteps: this.config.maxStepsPerAgent,
|
|
271
|
+
requestApproval: this.requestApproval,
|
|
272
|
+
requestQuestion: this.requestQuestion,
|
|
273
|
+
images,
|
|
274
|
+
price: priceFor(resolved.provider, resolved.model),
|
|
275
|
+
skills: this.getSkills(),
|
|
276
|
+
specialist,
|
|
277
|
+
projectMemory: this.projectMemory(),
|
|
278
|
+
historyFile,
|
|
279
|
+
initialHistory,
|
|
280
|
+
});
|
|
281
|
+
if (historyFile)
|
|
282
|
+
this.conversationFiles.set(id, historyFile);
|
|
283
|
+
this.agents.set(id, agent);
|
|
284
|
+
void agent.run();
|
|
285
|
+
// Multi-terminal paradigm: each new agent gets its OWN terminal window
|
|
286
|
+
// (attached to this session) — unless the user disabled it (/attach off).
|
|
287
|
+
if (this.attachEnabled && this.autoAttach) {
|
|
288
|
+
const r = this.openTerminal(alias);
|
|
289
|
+
if (r === 'opened')
|
|
290
|
+
this.board.log('', 'system', t('m.attachOpened', { name: agentName }));
|
|
291
|
+
else
|
|
292
|
+
this.board.log('', 'system', t('m.attachManual', { cmd: `parallel attach ${alias}` }));
|
|
293
|
+
}
|
|
294
|
+
return agent;
|
|
295
|
+
}
|
|
296
|
+
// ---------- multi-terminal (session server + one terminal per agent) ----------
|
|
297
|
+
/** True once the session server listens on .parallel/session.sock (set by the UI). */
|
|
298
|
+
attachEnabled = false;
|
|
299
|
+
/** Auto-open a terminal per new agent (toggle: /attach on|off). */
|
|
300
|
+
autoAttach = true;
|
|
301
|
+
/**
|
|
302
|
+
* Open a NEW system terminal running `parallel attach <alias>` for this
|
|
303
|
+
* session. Best effort: tries the common terminal emulators; when none can
|
|
304
|
+
* be opened (SSH, no GUI…), the caller shows the manual command instead.
|
|
305
|
+
*/
|
|
306
|
+
openTerminal(alias) {
|
|
307
|
+
const cmd = [process.execPath, process.argv[1], 'attach', alias, '--root', this.projectRoot];
|
|
308
|
+
try {
|
|
309
|
+
if (process.platform === 'darwin') {
|
|
310
|
+
const sh = cmd.map((c) => `'${c.replace(/'/g, `'\\''`)}'`).join(' ');
|
|
311
|
+
const script = `tell application "Terminal" to do script "${sh.replace(/[\\"]/g, '\\$&')}"`;
|
|
312
|
+
spawn('osascript', ['-e', script, '-e', 'tell application "Terminal" to activate'], {
|
|
313
|
+
detached: true,
|
|
314
|
+
stdio: 'ignore',
|
|
315
|
+
}).unref();
|
|
316
|
+
return 'opened';
|
|
317
|
+
}
|
|
318
|
+
if (process.platform === 'linux' && (process.env.DISPLAY || process.env.WAYLAND_DISPLAY)) {
|
|
319
|
+
const candidates = [
|
|
320
|
+
['gnome-terminal', ['--', ...cmd]],
|
|
321
|
+
['konsole', ['-e', ...cmd]],
|
|
322
|
+
['xfce4-terminal', ['-x', ...cmd]],
|
|
323
|
+
['x-terminal-emulator', ['-e', ...cmd]],
|
|
324
|
+
['xterm', ['-e', ...cmd]],
|
|
325
|
+
];
|
|
326
|
+
for (const [bin, args] of candidates) {
|
|
327
|
+
try {
|
|
328
|
+
execFileSync('which', [bin], { stdio: 'ignore' });
|
|
329
|
+
}
|
|
330
|
+
catch {
|
|
331
|
+
continue;
|
|
332
|
+
}
|
|
333
|
+
spawn(bin, args, { detached: true, stdio: 'ignore' }).unref();
|
|
334
|
+
return 'opened';
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
catch {
|
|
339
|
+
/* fall through to manual */
|
|
340
|
+
}
|
|
341
|
+
return 'manual';
|
|
342
|
+
}
|
|
343
|
+
/**
|
|
344
|
+
* Relaunch an agent from a restored session (/restore <name>): its full
|
|
345
|
+
* conversation is reloaded from the saved JSONL, so it continues with its
|
|
346
|
+
* memory intact instead of starting from scratch.
|
|
347
|
+
*/
|
|
348
|
+
respawnAgent(name) {
|
|
349
|
+
const sa = this.loadedSession?.agents.find((a) => a.name.toLowerCase() === name.toLowerCase());
|
|
350
|
+
if (!sa)
|
|
351
|
+
return 'no-conversation';
|
|
352
|
+
if (!sa.conversation || !fs.existsSync(sa.conversation))
|
|
353
|
+
return 'no-conversation';
|
|
354
|
+
let history;
|
|
355
|
+
try {
|
|
356
|
+
history = fs
|
|
357
|
+
.readFileSync(sa.conversation, 'utf8')
|
|
358
|
+
.split('\n')
|
|
359
|
+
.filter(Boolean)
|
|
360
|
+
.map((l) => JSON.parse(l));
|
|
361
|
+
}
|
|
362
|
+
catch {
|
|
363
|
+
return 'no-conversation';
|
|
364
|
+
}
|
|
365
|
+
if (history.length === 0)
|
|
366
|
+
return 'no-conversation';
|
|
367
|
+
return this.spawnAgent(sa.task, sa.name, sa.model, undefined, undefined, history);
|
|
368
|
+
}
|
|
369
|
+
pauseAgent(name) {
|
|
370
|
+
const a = this.findAgent(name);
|
|
371
|
+
if (!a)
|
|
372
|
+
return false;
|
|
373
|
+
a.pause();
|
|
374
|
+
return true;
|
|
375
|
+
}
|
|
376
|
+
resumeAgent(name) {
|
|
377
|
+
const a = this.findAgent(name);
|
|
378
|
+
if (!a)
|
|
379
|
+
return false;
|
|
380
|
+
a.resume();
|
|
381
|
+
return true;
|
|
382
|
+
}
|
|
383
|
+
stopAgent(name) {
|
|
384
|
+
const a = this.findAgent(name);
|
|
385
|
+
if (!a)
|
|
386
|
+
return false;
|
|
387
|
+
a.stop();
|
|
388
|
+
return true;
|
|
389
|
+
}
|
|
390
|
+
stopAll() {
|
|
391
|
+
for (const a of this.agents.values())
|
|
392
|
+
a.stop();
|
|
393
|
+
for (const req of this.approvals.splice(0))
|
|
394
|
+
req.resolve(false);
|
|
395
|
+
for (const q of this.questions.splice(0))
|
|
396
|
+
q.resolve(q.options[q.recommended] ?? '');
|
|
397
|
+
}
|
|
398
|
+
sendToAgent(name, content) {
|
|
399
|
+
const a = this.findAgent(name);
|
|
400
|
+
if (!a)
|
|
401
|
+
return false;
|
|
402
|
+
a.instruct(content);
|
|
403
|
+
return true;
|
|
404
|
+
}
|
|
405
|
+
broadcast(content) {
|
|
406
|
+
this.board.addNote('user', 'all', content);
|
|
407
|
+
}
|
|
408
|
+
hasRunningAgents() {
|
|
409
|
+
return [...this.board.agents.values()].some((a) => ['working', 'thinking', 'listening', 'waiting', 'paused', 'idle'].includes(a.state));
|
|
410
|
+
}
|
|
411
|
+
/** Find a live agent by name OR alias (@a1, @a2, …). */
|
|
412
|
+
findAgent(name) {
|
|
413
|
+
const info = this.board.getAgentByName(name);
|
|
414
|
+
return info ? this.agents.get(info.id) : undefined;
|
|
415
|
+
}
|
|
416
|
+
// ---------- checkpoints (/undo) ----------
|
|
417
|
+
/**
|
|
418
|
+
* Revert the LAST file change of an agent by restoring the `before` content
|
|
419
|
+
* recorded on the blackboard. Returns the path reverted, plus a conflict
|
|
420
|
+
* flag when a LATER change by another agent touched the same file (the
|
|
421
|
+
* restore then also wipes that other agent's work — the user must know).
|
|
422
|
+
*/
|
|
423
|
+
undoAgent(name) {
|
|
424
|
+
const info = this.board.getAgentByName(name);
|
|
425
|
+
if (!info)
|
|
426
|
+
return null;
|
|
427
|
+
for (let i = this.board.changes.length - 1; i >= 0; i--) {
|
|
428
|
+
const c = this.board.changes[i];
|
|
429
|
+
if (c.agentId !== info.id)
|
|
430
|
+
continue;
|
|
431
|
+
const conflict = this.board.changes.some((c2) => c2.id > c.id && c2.path === c.path && c2.agentId !== info.id);
|
|
432
|
+
try {
|
|
433
|
+
const abs = path.resolve(this.projectRoot, c.path);
|
|
434
|
+
if (!abs.startsWith(path.resolve(this.projectRoot)))
|
|
435
|
+
return 'none';
|
|
436
|
+
fs.writeFileSync(abs, c.before);
|
|
437
|
+
}
|
|
438
|
+
catch {
|
|
439
|
+
return 'none';
|
|
440
|
+
}
|
|
441
|
+
this.board.changes.splice(i, 1);
|
|
442
|
+
this.board.log('', 'system', `↩ undo ${info.name}: ${c.path} restored to its previous content.`);
|
|
443
|
+
this.board.addNote('user', 'all', `The user reverted ${info.name}'s last change on ${c.path}. Re-read it before touching it.`);
|
|
444
|
+
this.emit('update');
|
|
445
|
+
return { path: c.path, conflict };
|
|
446
|
+
}
|
|
447
|
+
return 'none';
|
|
448
|
+
}
|
|
449
|
+
// ---------- git (/commit, /autocommit) ----------
|
|
450
|
+
/** Session toggle: commit each agent's files automatically at task_complete. */
|
|
451
|
+
autoCommit = false;
|
|
452
|
+
/**
|
|
453
|
+
* Commit the files touched by ONE agent (or by everyone with 'all'),
|
|
454
|
+
* staged by explicit path — never `git add -A`. The commit is signed
|
|
455
|
+
* `parallel(<agent>)` with a Co-Authored-By trailer per agent.
|
|
456
|
+
*/
|
|
457
|
+
commitFor(ref, message) {
|
|
458
|
+
const all = ref.toLowerCase() === 'all';
|
|
459
|
+
const info = all ? undefined : this.board.getAgentByName(ref);
|
|
460
|
+
if (!all && !info)
|
|
461
|
+
return { ok: false, reason: 'not-found' };
|
|
462
|
+
const changes = this.board.changes.filter((c) => all || c.agentId === info.id);
|
|
463
|
+
const files = [...new Set(changes.map((c) => c.path))];
|
|
464
|
+
if (files.length === 0)
|
|
465
|
+
return { ok: false, reason: 'no-changes' };
|
|
466
|
+
const agents = all ? [...new Set(changes.map((c) => c.agentName))] : [info.name];
|
|
467
|
+
const subject = message?.trim() ||
|
|
468
|
+
(all
|
|
469
|
+
? `parallel: work of ${agents.join(', ')}`
|
|
470
|
+
: `parallel(${info.name}): ${info.task.replace(/\s+/g, ' ').slice(0, 60)}`);
|
|
471
|
+
const trailers = agents.map((n) => `Co-Authored-By: ${n} (Parallel agent) <agents@parallel-cli>`);
|
|
472
|
+
try {
|
|
473
|
+
execFileSync('git', ['add', '--', ...files], { cwd: this.projectRoot, stdio: 'pipe' });
|
|
474
|
+
execFileSync('git', ['commit', '-m', `${subject}\n\n${trailers.join('\n')}`], {
|
|
475
|
+
cwd: this.projectRoot,
|
|
476
|
+
stdio: 'pipe',
|
|
477
|
+
});
|
|
478
|
+
return { ok: true, files: files.length };
|
|
479
|
+
}
|
|
480
|
+
catch (e) {
|
|
481
|
+
const detail = String(e?.stderr ?? e?.stdout ?? e?.message ?? e)
|
|
482
|
+
.trim()
|
|
483
|
+
.split('\n')
|
|
484
|
+
.slice(-3)
|
|
485
|
+
.join(' ')
|
|
486
|
+
.slice(0, 200);
|
|
487
|
+
return { ok: false, reason: 'git', detail };
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
// ---------- user hooks (.parallel/hooks.json → { "afterWrite": "<command>" }) ----------
|
|
491
|
+
hookTimer = null;
|
|
492
|
+
hookRunning = false;
|
|
493
|
+
hooksConfig() {
|
|
494
|
+
try {
|
|
495
|
+
const f = path.join(this.projectRoot, '.parallel', 'hooks.json');
|
|
496
|
+
if (!fs.existsSync(f))
|
|
497
|
+
return null;
|
|
498
|
+
return JSON.parse(fs.readFileSync(f, 'utf8'));
|
|
499
|
+
}
|
|
500
|
+
catch {
|
|
501
|
+
return null;
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
/** Debounced (1.5s after the LAST write): one test run per burst of edits, not per file. */
|
|
505
|
+
scheduleAfterWriteHook() {
|
|
506
|
+
const cmd = this.hooksConfig()?.afterWrite;
|
|
507
|
+
if (!cmd || typeof cmd !== 'string')
|
|
508
|
+
return;
|
|
509
|
+
if (this.hookTimer)
|
|
510
|
+
clearTimeout(this.hookTimer);
|
|
511
|
+
this.hookTimer = setTimeout(() => {
|
|
512
|
+
this.hookTimer = null;
|
|
513
|
+
if (this.hookRunning)
|
|
514
|
+
return this.scheduleAfterWriteHook();
|
|
515
|
+
this.hookRunning = true;
|
|
516
|
+
exec(cmd, { cwd: this.projectRoot, timeout: 120_000 }, (err, stdout, stderr) => {
|
|
517
|
+
this.hookRunning = false;
|
|
518
|
+
const out = `${stdout ?? ''}${stderr ?? ''}`.trim().split('\n').slice(-6).join('\n');
|
|
519
|
+
this.board.log('', 'system', `⚓ hook afterWrite ${err ? '✗' : '✓'} (${cmd})${out ? `\n${out}` : ''}`);
|
|
520
|
+
});
|
|
521
|
+
}, 1500);
|
|
522
|
+
this.hookTimer.unref?.();
|
|
523
|
+
}
|
|
524
|
+
// ---------- GitHub Issues (/issue <n>, via the gh CLI) ----------
|
|
525
|
+
fetchIssue(n) {
|
|
526
|
+
try {
|
|
527
|
+
const raw = execFileSync('gh', ['issue', 'view', String(n), '--json', 'title,body,number'], {
|
|
528
|
+
cwd: this.projectRoot,
|
|
529
|
+
stdio: 'pipe',
|
|
530
|
+
timeout: 15_000,
|
|
531
|
+
});
|
|
532
|
+
const data = JSON.parse(String(raw));
|
|
533
|
+
return { title: String(data.title ?? ''), body: String(data.body ?? ''), number: Number(data.number ?? n) };
|
|
534
|
+
}
|
|
535
|
+
catch (e) {
|
|
536
|
+
if (e?.code === 'ENOENT')
|
|
537
|
+
return { error: 'gh-missing' };
|
|
538
|
+
const detail = String(e?.stderr ?? e?.message ?? e).trim().split('\n')[0].slice(0, 160);
|
|
539
|
+
return { error: detail || 'gh failed' };
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
// ---------- sessions (save / resume) ----------
|
|
543
|
+
sessionsDir() {
|
|
544
|
+
return path.join(this.projectRoot, '.parallel', 'sessions');
|
|
545
|
+
}
|
|
546
|
+
/**
|
|
547
|
+
* Save the session to a STABLE file (one per run, overwritten by the 30s
|
|
548
|
+
* autosave) — `/save <name>` additionally gives it a friendly name.
|
|
549
|
+
*/
|
|
550
|
+
saveSession(name) {
|
|
551
|
+
if (name)
|
|
552
|
+
this.sessionName = name;
|
|
553
|
+
if (this.board.agents.size === 0 && this.board.notes.length === 0)
|
|
554
|
+
return null;
|
|
555
|
+
try {
|
|
556
|
+
const dir = this.sessionsDir();
|
|
557
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
558
|
+
const data = {
|
|
559
|
+
savedAt: new Date().toISOString(),
|
|
560
|
+
name: this.sessionName,
|
|
561
|
+
projectRoot: this.projectRoot,
|
|
562
|
+
agents: [...this.board.agents.values()].map((a) => ({
|
|
563
|
+
name: a.name,
|
|
564
|
+
task: a.task,
|
|
565
|
+
state: a.state,
|
|
566
|
+
lastResult: a.lastResult,
|
|
567
|
+
steps: a.steps,
|
|
568
|
+
tokensIn: a.tokensIn,
|
|
569
|
+
tokensOut: a.tokensOut,
|
|
570
|
+
cost: a.cost,
|
|
571
|
+
model: a.model,
|
|
572
|
+
conversation: this.conversationFiles.get(a.id),
|
|
573
|
+
})),
|
|
574
|
+
notes: this.board.notes.slice(-200),
|
|
575
|
+
changedFiles: [...new Set(this.board.changes.map((c) => c.path))],
|
|
576
|
+
};
|
|
577
|
+
const file = path.join(dir, `session-${this.sessionStamp}.json`);
|
|
578
|
+
fs.writeFileSync(file, JSON.stringify(data, null, 2));
|
|
579
|
+
return file;
|
|
580
|
+
}
|
|
581
|
+
catch {
|
|
582
|
+
return null;
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
static listSessions(projectRoot) {
|
|
586
|
+
try {
|
|
587
|
+
const dir = path.join(projectRoot, '.parallel', 'sessions');
|
|
588
|
+
if (!fs.existsSync(dir))
|
|
589
|
+
return [];
|
|
590
|
+
return fs
|
|
591
|
+
.readdirSync(dir)
|
|
592
|
+
.filter((f) => f.endsWith('.json'))
|
|
593
|
+
.map((f) => {
|
|
594
|
+
const file = path.join(dir, f);
|
|
595
|
+
return { file, data: JSON.parse(fs.readFileSync(file, 'utf8')) };
|
|
596
|
+
})
|
|
597
|
+
.sort((a, b) => (a.data.savedAt < b.data.savedAt ? 1 : -1))
|
|
598
|
+
.slice(0, 8);
|
|
599
|
+
}
|
|
600
|
+
catch {
|
|
601
|
+
return [];
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
/** Restore the memory of a previous session into the blackboard. */
|
|
605
|
+
loadSession(data) {
|
|
606
|
+
this.loadedSession = data;
|
|
607
|
+
if (data.name)
|
|
608
|
+
this.sessionName = data.name;
|
|
609
|
+
const tasks = data.agents.map((a) => `${a.name} [${a.state}] : ${a.task}${a.lastResult ? ` → ${a.lastResult}` : ''}`);
|
|
610
|
+
this.board.addNote('system', 'all', `Previous session restored (${data.savedAt}). Past work:\n${tasks.join('\n')}\nFiles changed then: ${data.changedFiles.join(', ') || '(none)'}`);
|
|
611
|
+
for (const n of data.notes.slice(-50)) {
|
|
612
|
+
this.board.notes.push({ ...n, id: this.board.notes.length + 1 });
|
|
613
|
+
}
|
|
614
|
+
this.board.log('', 'system', t('m.sessionRestored', { date: new Date(data.savedAt).toLocaleString() }));
|
|
615
|
+
// Financial history: per-agent cost/steps/tokens of the restored session.
|
|
616
|
+
const withCost = data.agents.filter((a) => a.cost !== undefined || a.tokensIn !== undefined);
|
|
617
|
+
if (withCost.length > 0) {
|
|
618
|
+
const fmt = (n) => (n === null || n === undefined ? '—' : fmtCost(n));
|
|
619
|
+
const lines = withCost.map((a) => ` ${a.name} (${a.model ?? '?'}) · ${a.steps ?? 0} steps · ${Math.round(((a.tokensIn ?? 0) + (a.tokensOut ?? 0)) / 1000)}k tok · ${fmt(a.cost)}`);
|
|
620
|
+
const total = withCost.reduce((s, a) => s + (a.cost ?? 0), 0);
|
|
621
|
+
this.board.log('', 'system', t('m.costHistory', { total: fmtCost(total) }) + '\n' + lines.join('\n'));
|
|
622
|
+
}
|
|
623
|
+
this.emit('update');
|
|
624
|
+
}
|
|
625
|
+
// ---------- SESSION settings (/settings-session, /model) — never persisted ----------
|
|
626
|
+
setSessionModel(spec) {
|
|
627
|
+
const r = this.resolveModel(spec);
|
|
628
|
+
if (!r)
|
|
629
|
+
return null;
|
|
630
|
+
this.session.providerName = r.provider.name;
|
|
631
|
+
this.session.model = r.model;
|
|
632
|
+
this.emit('update');
|
|
633
|
+
return { provider: r.provider.name, model: r.model };
|
|
634
|
+
}
|
|
635
|
+
setSessionProvider(name) {
|
|
636
|
+
const p = getProvider(this.config, name);
|
|
637
|
+
if (!p)
|
|
638
|
+
return false;
|
|
639
|
+
this.session.providerName = p.name;
|
|
640
|
+
this.session.model = p.defaultModel;
|
|
641
|
+
this.emit('update');
|
|
642
|
+
return true;
|
|
643
|
+
}
|
|
644
|
+
setSessionApprovalMode(mode) {
|
|
645
|
+
this.session.approvalMode = mode;
|
|
646
|
+
this.emit('update');
|
|
647
|
+
}
|
|
648
|
+
setSessionSound(enabled) {
|
|
649
|
+
this.session.soundEnabled = enabled;
|
|
650
|
+
this.emit('update');
|
|
651
|
+
}
|
|
652
|
+
// ---------- GLOBAL settings (/settings) — persisted ----------
|
|
653
|
+
saveProvider(p) {
|
|
654
|
+
upsertProvider(this.config, p);
|
|
655
|
+
this.llmCache.clear();
|
|
656
|
+
// if the session points at this provider, refresh its view
|
|
657
|
+
if (this.session.providerName.toLowerCase() === p.name.toLowerCase()) {
|
|
658
|
+
this.session.providerName = p.name;
|
|
659
|
+
if (!p.models.includes(this.session.model))
|
|
660
|
+
this.session.model = p.defaultModel;
|
|
661
|
+
}
|
|
662
|
+
if (!this.session.providerName) {
|
|
663
|
+
this.session.providerName = p.name;
|
|
664
|
+
this.session.model = p.defaultModel;
|
|
665
|
+
}
|
|
666
|
+
this.emit('update');
|
|
667
|
+
}
|
|
668
|
+
setDefaultProvider(name) {
|
|
669
|
+
const p = getProvider(this.config, name);
|
|
670
|
+
if (!p)
|
|
671
|
+
return false;
|
|
672
|
+
this.config.defaultProvider = p.name;
|
|
673
|
+
saveConfig(this.config);
|
|
674
|
+
this.emit('update');
|
|
675
|
+
return true;
|
|
676
|
+
}
|
|
677
|
+
/** Set the API key of the CURRENT session provider (persisted globally). */
|
|
678
|
+
setApiKey(key) {
|
|
679
|
+
const p = this.sessionProvider();
|
|
680
|
+
if (!p)
|
|
681
|
+
return false;
|
|
682
|
+
p.apiKey = key;
|
|
683
|
+
saveConfig(this.config);
|
|
684
|
+
this.llmCache.clear();
|
|
685
|
+
this.emit('update');
|
|
686
|
+
return true;
|
|
687
|
+
}
|
|
688
|
+
setGlobalApprovalMode(mode) {
|
|
689
|
+
this.config.approvalMode = mode;
|
|
690
|
+
saveConfig(this.config);
|
|
691
|
+
this.emit('update');
|
|
692
|
+
}
|
|
693
|
+
setGlobalSound(enabled) {
|
|
694
|
+
this.config.soundEnabled = enabled;
|
|
695
|
+
saveConfig(this.config);
|
|
696
|
+
this.emit('update');
|
|
697
|
+
}
|
|
698
|
+
setLanguage(lang) {
|
|
699
|
+
this.config.language = lang;
|
|
700
|
+
saveConfig(this.config);
|
|
701
|
+
this.emit('update');
|
|
702
|
+
}
|
|
703
|
+
}
|