@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.
@@ -0,0 +1,518 @@
1
+ import fs from 'node:fs';
2
+ import * as Diff from 'diff';
3
+ import { ToolExecutor, TOOL_DEFINITIONS } from './tools.js';
4
+ import { costOf } from '../pricing.js';
5
+ import { skillsCatalog } from '../skills.js';
6
+ import { getLang, LANG_NAME_EN } from '../i18n.js';
7
+ // Agent-facing prompts stay in English (canonical for models). Only notes
8
+ // addressed to the user follow the configured UI language.
9
+ const SYSTEM_PROMPT = (name, task, userLang, skillsList, specialist, projectMemory) => `You are agent "${name}", an autonomous software engineer inside PARALLEL, an environment where SEVERAL agents work at the same time on the SAME project, each on its own task given by the user.
10
+ ${specialist
11
+ ? `
12
+ YOUR ROLE — you are the "${specialist.name}" specialist:
13
+ ${specialist.role}
14
+ `
15
+ : ''}
16
+ YOUR TASK: ${task}
17
+ ${skillsList
18
+ ? `
19
+ SKILLS — instructions written by the user, loadable on demand (load_skill):
20
+ ${skillsList}
21
+ If a skill's description matches your task, load it BEFORE starting the related work and follow it.
22
+ `
23
+ : ''}${projectMemory
24
+ ? `
25
+ PROJECT MEMORY — durable facts recorded by previous agents on this project. Trust them, but verify in the code when critical:
26
+ ${projectMemory}
27
+ `
28
+ : ''}
29
+
30
+ PARALLEL'S PHILOSOPHY — REAL-TIME CO-EDITING, NEVER ANY BLOCKING:
31
+ 1. No file is ever locked. You MAY modify a file another agent is working on, if it moves your task forward.
32
+ 2. In return, you must NEVER break another agent's work: before every call you receive the live state of the other agents and the DIFFS of their recent changes. Read them and understand what they imply for your task.
33
+ 3. If another agent modified a file you rely on: re-read it (read_file), integrate their changes, overwrite NOTHING of their work. Build ON TOP.
34
+ 4. Continuously announce what you are doing (update_status) and your intentions on shared areas (post_note): the others adapt to you just as you adapt to them.
35
+ 5. If your work may conflict with someone else's (same function, same interface), coordinate by note BEFORE editing: propose a contract (signatures, formats), or ask them to adjust.
36
+ 6. If you receive a note (from an agent or the user), take it into account immediately and adapt your plan.
37
+ 7. Always make progress: never sit waiting for another agent. There is always a part of your task doable right now.
38
+ 8. MAKE THE SHARED AWARENESS VISIBLE: when another agent's work influenced a decision of yours (you reused their function, adapted to their diff, avoided their work area), SAY IT explicitly — in a post_note to them ("I saw you changed X, so I did Y") and in your task_complete summary (name the agent and what you built on). The user must be able to SEE that you worked as a team, not as isolated bots.
39
+
40
+ WORK METHOD:
41
+ - Explore first (list_files, read_file, search) before modifying.
42
+ - Declare your work area with claim_files when you start (and when it changes): it prevents collisions without ever locking anything.
43
+ - If you discover a durable, non-obvious fact about the project (convention, decision, pitfall), save it with remember(fact) for future agents.
44
+ - wait_for_agent exists for hard dependencies only — prefer progressing on another part of your task over waiting.
45
+ - Prefer edit_file (targeted changes) over write_file (full rewrite): targeted edits coexist better in parallel.
46
+ - Make minimal changes; do not rewrite what already works.
47
+ - Verify your work when relevant (run_command for tests/build), then finish with task_complete.
48
+ - The task_complete summary is user-facing. Make it structured and specific in ${userLang}, not just "done":
49
+ 1. What I did — concrete outcome in 1-2 sentences.
50
+ 2. Files changed or inspected — mention paths when relevant.
51
+ 3. Validation — exact command(s) run and result, or say what was not run.
52
+ 4. Remaining caveats / next step — only if useful.
53
+ - Never invent a file's content: read it. And re-read it if it changed.
54
+
55
+ LANGUAGE: write notes addressed to "user" and your task_complete summary in ${userLang}. Notes to other agents and code stay in English.`;
56
+ /** Assumed context window (tokens) when the provider does not advertise one. */
57
+ const CONTEXT_WINDOW = 128_000;
58
+ export class Agent {
59
+ opts;
60
+ id;
61
+ name;
62
+ history = [];
63
+ executor;
64
+ llm;
65
+ board;
66
+ maxSteps;
67
+ abort = new AbortController();
68
+ paused = false;
69
+ stopped = false;
70
+ lastNoteId = 0;
71
+ lastChangeId = 0;
72
+ constructor(opts) {
73
+ this.opts = opts;
74
+ this.id = opts.id;
75
+ this.name = opts.name;
76
+ this.llm = opts.llm;
77
+ this.board = opts.board;
78
+ this.maxSteps = opts.maxSteps;
79
+ this.executor = new ToolExecutor(opts.board, opts.id, opts.name, opts.projectRoot, opts.requestApproval, opts.requestQuestion, opts.skills);
80
+ const info = {
81
+ id: opts.id,
82
+ name: opts.name,
83
+ alias: opts.alias,
84
+ color: opts.color,
85
+ task: opts.task,
86
+ model: opts.model,
87
+ state: 'idle',
88
+ currentAction: '',
89
+ steps: 0,
90
+ tokensIn: 0,
91
+ tokensOut: 0,
92
+ cost: opts.price ? 0 : null,
93
+ startedAt: Date.now(),
94
+ specialist: opts.specialist?.name,
95
+ };
96
+ this.board.registerAgent(info);
97
+ // Skip notes/changes that existed before this agent was born
98
+ this.lastNoteId = this.board.notes.length > 0 ? this.board.notes[this.board.notes.length - 1].id : 0;
99
+ this.lastChangeId = this.board.lastChangeId();
100
+ }
101
+ pause() {
102
+ this.paused = true;
103
+ this.board.setAgentState(this.id, 'paused');
104
+ }
105
+ resume() {
106
+ this.paused = false;
107
+ this.board.setAgentState(this.id, 'working');
108
+ }
109
+ stop() {
110
+ this.stopped = true;
111
+ this.abort.abort();
112
+ this.board.setAgentState(this.id, 'stopped');
113
+ }
114
+ // ---------- real-time steering (User → Agent N) ----------
115
+ steerQueue = [];
116
+ llmAbort = null;
117
+ steered = false;
118
+ /** True once the run loop has exited (done / error / stopped / step limit). */
119
+ finished = false;
120
+ /**
121
+ * Inject a user instruction mid-run (from @Agent or /send).
122
+ * REAL steering: if the agent is thinking, the in-flight model call is
123
+ * aborted so the message is handled NOW, not several steps later.
124
+ * If the agent already FINISHED, the conversation simply continues: the
125
+ * follow-up reopens the run loop with the full history intact.
126
+ */
127
+ instruct(content) {
128
+ this.steerQueue.push(content);
129
+ this.board.log(this.id, 'note', `📨 user → ${this.name}: ${content}`);
130
+ if (this.finished) {
131
+ this.finished = false;
132
+ this.stopped = false;
133
+ this.paused = false;
134
+ this.abort = new AbortController();
135
+ this.board.setAgentState(this.id, 'working', 'follow-up');
136
+ void this.loop();
137
+ return;
138
+ }
139
+ this.steered = true;
140
+ this.llmAbort?.abort();
141
+ }
142
+ /**
143
+ * A note addressed to this agent just arrived: interrupt the current model
144
+ * call so the next turn (which injects unread notes) starts immediately.
145
+ */
146
+ nudge() {
147
+ this.steered = true;
148
+ this.llmAbort?.abort();
149
+ }
150
+ /**
151
+ * Append a message to the in-memory history AND to the conversation file
152
+ * (JSONL, one message per line) — the file is what makes /restore possible.
153
+ */
154
+ record(msg) {
155
+ this.history.push(msg);
156
+ if (this.opts.historyFile) {
157
+ try {
158
+ fs.appendFileSync(this.opts.historyFile, JSON.stringify(msg) + '\n');
159
+ }
160
+ catch {
161
+ // best effort — never let persistence break the agent
162
+ }
163
+ }
164
+ }
165
+ async waitWhilePaused() {
166
+ while (this.paused && !this.stopped) {
167
+ await new Promise((r) => setTimeout(r, 300));
168
+ }
169
+ }
170
+ /**
171
+ * Build the live context injected before EVERY model call:
172
+ * other agents' status + their fresh diffs + unread notes.
173
+ * Returns { text, hasNews } — hasNews drives the 'listening' state.
174
+ */
175
+ liveContext() {
176
+ let hasNews = false;
177
+ const parts = ['[REAL TIME]', this.board.snapshotFor(this.id)];
178
+ const changes = this.board.changesSince(this.id, this.lastChangeId);
179
+ if (changes.length > 0) {
180
+ this.lastChangeId = changes[changes.length - 1].id;
181
+ hasNews = true;
182
+ parts.push('\n[LIVE DIFFS — changes made by the other agents since your last turn]');
183
+ // group by file, keep the most recent change per file, max 4 files
184
+ const byFile = new Map();
185
+ for (const c of changes)
186
+ byFile.set(c.path, c);
187
+ let shown = 0;
188
+ for (const c of byFile.values()) {
189
+ if (shown >= 4) {
190
+ parts.push(` … and ${byFile.size - shown} more modified file(s).`);
191
+ break;
192
+ }
193
+ const patch = Diff.createPatch(c.path, c.before, c.after, '', '', { context: 1 });
194
+ const excerpt = patch.split('\n').slice(4, 22).join('\n');
195
+ parts.push(`--- ${c.agentName} modified ${c.path}:\n${excerpt}`);
196
+ shown++;
197
+ }
198
+ parts.push('Analyze these diffs: if any of them touches your work area, re-read the affected file and adapt. NEVER undo these changes.');
199
+ }
200
+ const notes = this.board.notesFor(this.name, this.lastNoteId);
201
+ if (notes.length > 0) {
202
+ this.lastNoteId = notes[notes.length - 1].id;
203
+ hasNews = true;
204
+ parts.push('\n[NOTES RECEIVED — take them into account now]');
205
+ for (const n of notes) {
206
+ parts.push(` • from ${n.from}: ${n.content}`);
207
+ }
208
+ }
209
+ parts.push('\nContinue your task taking the above into account. Use tools, or task_complete if finished.');
210
+ return { text: parts.join('\n'), hasNews };
211
+ }
212
+ async run() {
213
+ this.board.setAgentState(this.id, 'working', 'starting');
214
+ if (this.opts.initialHistory && this.opts.initialHistory.length > 0) {
215
+ // Resume a previous conversation (/restore): re-record everything into
216
+ // the new conversation file, then tell the agent the world may have moved.
217
+ this.history = [];
218
+ for (const m of this.opts.initialHistory)
219
+ this.record(m);
220
+ this.record({
221
+ role: 'user',
222
+ content: '[SESSION RESTORED] This conversation was saved and has just been restored. Time has passed: files may have changed on disk. Re-read the files you rely on before editing them, then continue your task from where you left off.',
223
+ });
224
+ }
225
+ else {
226
+ this.record({
227
+ role: 'system',
228
+ content: SYSTEM_PROMPT(this.name, this.opts.task, LANG_NAME_EN[getLang()], skillsCatalog(this.opts.skills), this.opts.specialist, this.opts.projectMemory),
229
+ });
230
+ // Pasted images (multimodal models): attached to the very first user turn.
231
+ if (this.opts.images && this.opts.images.length > 0) {
232
+ this.record({
233
+ role: 'user',
234
+ content: [
235
+ { type: 'text', text: 'The user attached the following image(s) to the task. Use them as visual context.' },
236
+ ...this.opts.images.map((url) => ({ type: 'image_url', image_url: { url } })),
237
+ ],
238
+ });
239
+ }
240
+ }
241
+ await this.loop();
242
+ }
243
+ /**
244
+ * The agent's action loop. Extracted from run() so a user follow-up can
245
+ * REOPEN a finished conversation (fresh step budget, same history).
246
+ */
247
+ async loop() {
248
+ let steps = 0;
249
+ try {
250
+ this.finished = false;
251
+ while (!this.stopped && steps < this.maxSteps) {
252
+ await this.waitWhilePaused();
253
+ if (this.stopped)
254
+ break;
255
+ steps++;
256
+ this.board.updateAgent(this.id, { steps });
257
+ // User steering messages first — they take priority over everything.
258
+ for (const m of this.steerQueue.splice(0)) {
259
+ this.record({
260
+ role: 'user',
261
+ content: `[USER MESSAGE — priority] ${m}\nTake this into account NOW and adapt your plan. The user's word overrides your previous plan.`,
262
+ });
263
+ }
264
+ // Fresh, real-time view of the other agents before EVERY model call.
265
+ const live = this.liveContext();
266
+ if (live.hasNews) {
267
+ // Visible (and audible via state event) cue: the agent is listening to the others.
268
+ this.board.setAgentState(this.id, 'listening', 'reading the other agents’ work…');
269
+ await new Promise((r) => setTimeout(r, 600));
270
+ if (this.stopped)
271
+ break;
272
+ }
273
+ const messages = [
274
+ ...this.history,
275
+ { role: 'user', content: live.text },
276
+ ];
277
+ this.board.setAgentState(this.id, 'thinking');
278
+ // Per-call abort controller: instruct()/nudge() abort the in-flight
279
+ // call so steering and fresh notes are handled IMMEDIATELY.
280
+ this.llmAbort = new AbortController();
281
+ const onStop = () => this.llmAbort?.abort();
282
+ this.abort.signal.addEventListener('abort', onStop, { once: true });
283
+ let res;
284
+ try {
285
+ res = await this.llm.chat(messages, TOOL_DEFINITIONS, this.llmAbort.signal);
286
+ }
287
+ catch (err) {
288
+ if (!this.stopped && this.steered) {
289
+ // Interrupted on purpose (steering/nudge): not an error — retry the
290
+ // turn right away, with the new message/notes injected.
291
+ this.steered = false;
292
+ steps--;
293
+ this.board.updateAgent(this.id, { steps });
294
+ continue;
295
+ }
296
+ throw err;
297
+ }
298
+ finally {
299
+ this.abort.signal.removeEventListener('abort', onStop);
300
+ this.llmAbort = null;
301
+ }
302
+ this.steered = false;
303
+ const a = this.board.agents.get(this.id);
304
+ if (a) {
305
+ // Real-time financial view: accrue the cost of this round immediately.
306
+ const price = this.opts.price;
307
+ this.board.updateAgent(this.id, {
308
+ tokensIn: a.tokensIn + res.tokensIn,
309
+ tokensOut: a.tokensOut + res.tokensOut,
310
+ cost: price && a.cost !== null ? a.cost + costOf(price, res.tokensIn, res.tokensOut) : a.cost,
311
+ // res.tokensIn = prompt tokens of THIS round = the whole conversation
312
+ // sent to the model → direct estimate of context-window usage.
313
+ ctxPct: Math.min(100, Math.round((res.tokensIn / CONTEXT_WINDOW) * 100)),
314
+ });
315
+ }
316
+ const msg = res.message;
317
+ // Persist this round into history (live context is NOT kept — rebuilt fresh each turn).
318
+ this.record({ role: 'user', content: '[real-time state consulted]' });
319
+ this.record(msg);
320
+ if (msg.content && msg.content.trim()) {
321
+ // "✻" marks thinking/commentary steps — visually distinct from tool lines.
322
+ this.board.log(this.id, 'llm', `✻ ${msg.content.trim().slice(0, 500)}`);
323
+ }
324
+ const toolCalls = msg.tool_calls ?? [];
325
+ if (toolCalls.length === 0) {
326
+ this.record({
327
+ role: 'user',
328
+ content: 'No tool was called. If your task is finished and verified, call task_complete. Otherwise, continue with tool calls.',
329
+ });
330
+ continue;
331
+ }
332
+ this.board.setAgentState(this.id, 'working');
333
+ let completed = false;
334
+ for (const tc of toolCalls) {
335
+ if (this.stopped)
336
+ break;
337
+ if (tc.type !== 'function')
338
+ continue;
339
+ let args = {};
340
+ try {
341
+ args = tc.function.arguments ? JSON.parse(tc.function.arguments) : {};
342
+ }
343
+ catch {
344
+ this.record({
345
+ role: 'tool',
346
+ tool_call_id: tc.id,
347
+ content: 'ERROR: invalid JSON arguments.',
348
+ });
349
+ continue;
350
+ }
351
+ const label = this.describeCall(tc.function.name, args);
352
+ // run_command logs itself with its output; post_note is already
353
+ // rendered as the note itself — logging "✉ note → user" next to the
354
+ // actual note text was just visual noise.
355
+ if (tc.function.name !== 'run_command' && tc.function.name !== 'post_note') {
356
+ this.board.log(this.id, 'tool', label);
357
+ }
358
+ this.board.updateAgent(this.id, { currentAction: label.slice(0, 80) });
359
+ const result = await this.executor.execute(tc.function.name, args);
360
+ if (result === '__TASK_COMPLETE__') {
361
+ completed = true;
362
+ const summary = String(args.summary ?? 'Task complete.');
363
+ this.board.updateAgent(this.id, { lastResult: summary });
364
+ // ONE short headline note (the full summary lives in lastResult and
365
+ // is rendered as the agent's recap) — no duplicated walls of text.
366
+ const headline = summary.split('\n').find((l) => l.trim())?.trim() ?? 'Task complete.';
367
+ this.board.addNote(this.name, 'all', `✅ ${headline.slice(0, 160)}`);
368
+ this.record({ role: 'tool', tool_call_id: tc.id, content: 'OK, task closed.' });
369
+ break;
370
+ }
371
+ this.record({ role: 'tool', tool_call_id: tc.id, content: result });
372
+ }
373
+ if (completed) {
374
+ this.board.setAgentState(this.id, 'done', 'done ✅');
375
+ return;
376
+ }
377
+ await this.compactHistory();
378
+ }
379
+ if (!this.stopped) {
380
+ this.board.setAgentState(this.id, 'error', `step limit of ${this.maxSteps} reached`);
381
+ this.board.addNote(this.name, 'all', `⚠ I reached my step limit without finishing.`);
382
+ }
383
+ }
384
+ catch (err) {
385
+ if (this.stopped)
386
+ return;
387
+ this.board.setAgentState(this.id, 'error', (err?.message ?? String(err)).slice(0, 80));
388
+ this.board.log(this.id, 'error', `Fatal error: ${err?.message ?? String(err)}`);
389
+ }
390
+ finally {
391
+ // The loop has exited (done / error / stopped / step limit): a future
392
+ // instruct() must REVIVE the agent instead of just queueing the message.
393
+ this.finished = true;
394
+ }
395
+ }
396
+ describeCall(name, args) {
397
+ switch (name) {
398
+ case 'read_file':
399
+ return `📖 read ${args.path}`;
400
+ case 'write_file':
401
+ return `✏ write ${args.path}`;
402
+ case 'edit_file':
403
+ return `✏ edit ${args.path}`;
404
+ case 'list_files':
405
+ return `📁 ls ${args.path ?? '.'}`;
406
+ case 'search':
407
+ return `🔍 search /${args.pattern}/`;
408
+ case 'run_command':
409
+ return `$ ${args.command}`;
410
+ case 'post_note':
411
+ return `✉ note → ${args.to}`;
412
+ case 'update_status':
413
+ return `📢 ${args.status}`;
414
+ case 'ask_user':
415
+ return `❓ ${String(args.question ?? '').slice(0, 60)}`;
416
+ case 'load_skill':
417
+ return `🧩 skill ${args.name}`;
418
+ case 'claim_files':
419
+ return `🚩 claim ${Array.isArray(args.paths) ? args.paths.join(' ') : ''}`;
420
+ case 'wait_for_agent':
421
+ return `⏳ wait ${args.name ?? ''}`;
422
+ case 'remember':
423
+ return `🧠 remember`;
424
+ case 'task_complete':
425
+ return '✅ task_complete';
426
+ default:
427
+ return name;
428
+ }
429
+ }
430
+ // ---------- history compaction (LLM summary instead of blind truncation) ----------
431
+ compacting = false;
432
+ /**
433
+ * When the conversation grows too long, replace the oldest rounds with a
434
+ * REAL summary produced by the model (files touched, commands run,
435
+ * decisions, current state) — the agent keeps its memory instead of
436
+ * forgetting its own past. Falls back to plain truncation on failure.
437
+ */
438
+ async compactHistory() {
439
+ const MAX_MSGS = 80;
440
+ const KEEP_RECENT = 40;
441
+ if (this.history.length <= MAX_MSGS || this.compacting)
442
+ return;
443
+ this.compacting = true;
444
+ try {
445
+ // Remove whole rounds from index 1 (system prompt stays at 0) until
446
+ // only ~KEEP_RECENT recent messages remain after it.
447
+ const removed = [];
448
+ while (this.history.length > KEEP_RECENT + 1) {
449
+ const m = this.history[1];
450
+ if (m.role === 'assistant' && m.tool_calls) {
451
+ let j = 2;
452
+ while (j < this.history.length && this.history[j].role === 'tool')
453
+ j++;
454
+ removed.push(...this.history.splice(1, j - 1));
455
+ }
456
+ else {
457
+ removed.push(this.history.splice(1, 1)[0]);
458
+ }
459
+ }
460
+ if (removed.length === 0)
461
+ return;
462
+ // Compact transcript of what is being dropped (bounded).
463
+ const lines = [];
464
+ let total = 0;
465
+ for (const m of removed) {
466
+ let line = '';
467
+ if (m.role === 'assistant') {
468
+ const calls = (m.tool_calls ?? [])
469
+ .map((tc) => `${tc.function?.name}(${String(tc.function?.arguments ?? '').slice(0, 120)})`)
470
+ .join('; ');
471
+ line = `ASSISTANT: ${String(m.content ?? '').slice(0, 300)}${calls ? ` [tools: ${calls}]` : ''}`;
472
+ }
473
+ else if (m.role === 'tool') {
474
+ line = `TOOL RESULT: ${String(m.content ?? '').slice(0, 300)}`;
475
+ }
476
+ else {
477
+ line = `${String(m.role).toUpperCase()}: ${String(m.content ?? '').slice(0, 300)}`;
478
+ }
479
+ if (total + line.length > 12000)
480
+ break;
481
+ lines.push(line);
482
+ total += line.length;
483
+ }
484
+ this.board.log(this.id, 'system', '🗜 compacting history (LLM summary)…');
485
+ const res = await this.llm.chat([
486
+ {
487
+ role: 'system',
488
+ content: 'You compress an agent conversation. Produce a factual summary in AT MOST 15 bullet points covering: files read/modified (paths), commands run and their results, key decisions made, and the current state of the work. No fluff. English.',
489
+ },
490
+ { role: 'user', content: lines.join('\n') },
491
+ ], undefined, this.abort.signal);
492
+ const a = this.board.agents.get(this.id);
493
+ if (a) {
494
+ const price = this.opts.price;
495
+ this.board.updateAgent(this.id, {
496
+ tokensIn: a.tokensIn + res.tokensIn,
497
+ tokensOut: a.tokensOut + res.tokensOut,
498
+ cost: price && a.cost !== null ? a.cost + costOf(price, res.tokensIn, res.tokensOut) : a.cost,
499
+ });
500
+ }
501
+ const content = String(res.message.content ?? '').trim();
502
+ this.history.splice(1, 0, {
503
+ role: 'user',
504
+ content: `[MEMORY — compacted summary of your earlier work in this task]\n${content || '(summary unavailable)'}`,
505
+ });
506
+ }
507
+ catch {
508
+ // Fallback: plain truncation note (the rounds are already dropped).
509
+ this.history.splice(1, 0, {
510
+ role: 'user',
511
+ content: '(Note: the beginning of the conversation was truncated to save context. Your task is unchanged — re-read files if needed.)',
512
+ });
513
+ }
514
+ finally {
515
+ this.compacting = false;
516
+ }
517
+ }
518
+ }