@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,225 @@
1
+ import { EventEmitter } from 'node:events';
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ /**
5
+ * The Blackboard is the shared, real-time awareness space of Parallel.
6
+ *
7
+ * Philosophy: NOTHING here ever blocks an agent. Files are never locked.
8
+ * Instead, every agent sees — before every single model call — what the
9
+ * others are doing right now: their status, the files they touch, and the
10
+ * actual diffs of their recent edits. Each agent adapts continuously,
11
+ * integrates the others' work, and communicates through notes.
12
+ */
13
+ export class Blackboard extends EventEmitter {
14
+ projectRoot;
15
+ agents = new Map();
16
+ fileActivity = new Map(); // path -> last touch
17
+ notes = [];
18
+ changes = [];
19
+ logs = [];
20
+ noteSeq = 0;
21
+ changeSeq = 0;
22
+ logSeq = 0;
23
+ persistTimer = null;
24
+ constructor(projectRoot) {
25
+ super();
26
+ this.projectRoot = projectRoot;
27
+ this.setMaxListeners(64);
28
+ }
29
+ touch() {
30
+ this.emit('update');
31
+ this.schedulePersist();
32
+ }
33
+ // ---------- agents ----------
34
+ registerAgent(info) {
35
+ this.agents.set(info.id, info);
36
+ this.log('', 'system', `Agent ${info.name} launched — task: ${info.task}`);
37
+ this.emit('agent-event', { type: 'spawn', id: info.id });
38
+ this.touch();
39
+ }
40
+ updateAgent(id, patch) {
41
+ const a = this.agents.get(id);
42
+ if (!a)
43
+ return;
44
+ Object.assign(a, patch);
45
+ this.touch();
46
+ }
47
+ setAgentState(id, state, action) {
48
+ const a = this.agents.get(id);
49
+ if (!a)
50
+ return;
51
+ const prev = a.state;
52
+ a.state = state;
53
+ if (action !== undefined)
54
+ a.currentAction = action;
55
+ // A finished agent no longer holds any declared work area.
56
+ if (state === 'done' || state === 'stopped' || state === 'error')
57
+ a.claims = undefined;
58
+ if (prev !== state)
59
+ this.emit('agent-event', { type: 'state', id, state, prev });
60
+ this.touch();
61
+ }
62
+ /** Find an agent by its name OR its short alias (@a1, @a2, …). */
63
+ getAgentByName(name) {
64
+ const lower = name.toLowerCase();
65
+ for (const a of this.agents.values()) {
66
+ if (a.name.toLowerCase() === lower || a.alias?.toLowerCase() === lower)
67
+ return a;
68
+ }
69
+ return undefined;
70
+ }
71
+ // ---------- file activity (awareness, never blocking) ----------
72
+ recordActivity(relPath, agentId, op) {
73
+ const agent = this.agents.get(agentId);
74
+ this.fileActivity.set(relPath, {
75
+ path: relPath,
76
+ agentId,
77
+ agentName: agent?.name ?? agentId,
78
+ op,
79
+ ts: Date.now(),
80
+ });
81
+ this.touch();
82
+ }
83
+ // ---------- notes (inter-agent messages) ----------
84
+ addNote(from, to, content) {
85
+ // Normalize alias recipients (@a1 → canonical name) so notesFor() matches.
86
+ if (to !== 'all' && to !== 'user') {
87
+ const target = this.getAgentByName(to);
88
+ if (target)
89
+ to = target.name;
90
+ }
91
+ const note = { id: ++this.noteSeq, from, to, content, ts: Date.now() };
92
+ this.notes.push(note);
93
+ if (this.notes.length > 400)
94
+ this.notes.splice(0, this.notes.length - 400);
95
+ this.log('', 'note', `✉ ${from} → ${to}: ${content}`);
96
+ // Lets the controller nudge the recipient so it reads the note NOW
97
+ // instead of at its next natural turn.
98
+ this.emit('note', note);
99
+ this.touch();
100
+ return note;
101
+ }
102
+ /** Notes addressed to a given agent (by name) or to everyone, newer than `sinceId`. */
103
+ notesFor(agentName, sinceId) {
104
+ const lower = agentName.toLowerCase();
105
+ return this.notes.filter((n) => n.id > sinceId &&
106
+ n.from.toLowerCase() !== lower &&
107
+ (n.to === 'all' || n.to.toLowerCase() === lower));
108
+ }
109
+ // ---------- file changes (real-time diff feed) ----------
110
+ addChange(agentId, relPath, before, after) {
111
+ const agent = this.agents.get(agentId);
112
+ const change = {
113
+ id: ++this.changeSeq,
114
+ agentId,
115
+ agentName: agent?.name ?? agentId,
116
+ path: relPath,
117
+ before,
118
+ after,
119
+ ts: Date.now(),
120
+ };
121
+ this.changes.push(change);
122
+ if (this.changes.length > 300)
123
+ this.changes.splice(0, this.changes.length - 300);
124
+ // User hooks (.parallel/hooks.json → afterWrite) listen to this event.
125
+ this.emit('change', change);
126
+ this.touch();
127
+ return change;
128
+ }
129
+ /** Changes made by OTHER agents since a given change id — the live diff feed. */
130
+ changesSince(agentId, sinceId) {
131
+ return this.changes.filter((c) => c.id > sinceId && c.agentId !== agentId);
132
+ }
133
+ // ---------- conflict escalation (repeated co-edit collisions on one file) ----------
134
+ conflictCounts = new Map();
135
+ /**
136
+ * Record a co-edit collision on a file (REAL-TIME ADAPTATION triggered).
137
+ * Returns the running count so callers can escalate to the user when agents
138
+ * keep stepping on each other (>= 3 collisions on the same file).
139
+ */
140
+ recordConflict(relPath) {
141
+ const n = (this.conflictCounts.get(relPath) ?? 0) + 1;
142
+ this.conflictCounts.set(relPath, n);
143
+ if (n === 3)
144
+ this.emit('agent-event', { type: 'conflict', path: relPath });
145
+ return n;
146
+ }
147
+ lastChangeId() {
148
+ return this.changes.length > 0 ? this.changes[this.changes.length - 1].id : 0;
149
+ }
150
+ // ---------- logs ----------
151
+ log(agentId, kind, text) {
152
+ this.logs.push({ agentId, kind, text, ts: Date.now(), seq: ++this.logSeq });
153
+ if (this.logs.length > 2000)
154
+ this.logs.splice(0, this.logs.length - 2000);
155
+ this.emit('update');
156
+ }
157
+ logsFor(agentId, count) {
158
+ const out = [];
159
+ for (let i = this.logs.length - 1; i >= 0 && out.length < count; i--) {
160
+ if (this.logs[i].agentId === agentId)
161
+ out.push(this.logs[i]);
162
+ }
163
+ return out.reverse();
164
+ }
165
+ // ---------- live snapshot injected into every model call ----------
166
+ snapshotFor(agentId) {
167
+ const me = this.agents.get(agentId);
168
+ const lines = [];
169
+ lines.push('=== REAL-TIME STATE OF THE OTHER AGENTS ===');
170
+ const others = [...this.agents.values()].filter((a) => a.id !== agentId);
171
+ if (others.length === 0) {
172
+ lines.push('You are the only active agent for now.');
173
+ }
174
+ else {
175
+ for (const a of others) {
176
+ lines.push(` • ${a.name}${a.alias !== a.name ? ` (alias ${a.alias})` : ''} [${a.state}] — task: ${a.task}` +
177
+ (a.currentAction ? ` | right now: ${a.currentAction}` : '') +
178
+ (a.claims && a.claims.length > 0 ? ` | declared work area: ${a.claims.join(', ')}` : ''));
179
+ }
180
+ }
181
+ const activities = [...this.fileActivity.values()]
182
+ .sort((a, b) => b.ts - a.ts)
183
+ .slice(0, 10);
184
+ if (activities.length > 0) {
185
+ lines.push('Recent file activity (who works where):');
186
+ for (const act of activities) {
187
+ const mine = act.agentId === agentId;
188
+ const age = Math.round((Date.now() - act.ts) / 1000);
189
+ lines.push(` • ${act.path} — ${mine ? 'you' : act.agentName} (${act.op}, ${age}s ago)`);
190
+ }
191
+ }
192
+ if (me)
193
+ lines.push(`Reminder — your task: ${me.task}`);
194
+ lines.push('=== END OF REAL-TIME STATE ===');
195
+ return lines.join('\n');
196
+ }
197
+ // ---------- persistence (best effort, for inspection/debug) ----------
198
+ schedulePersist() {
199
+ if (this.persistTimer)
200
+ return;
201
+ this.persistTimer = setTimeout(() => {
202
+ this.persistTimer = null;
203
+ try {
204
+ const dir = path.join(this.projectRoot, '.parallel');
205
+ fs.mkdirSync(dir, { recursive: true });
206
+ const state = {
207
+ updatedAt: new Date().toISOString(),
208
+ agents: [...this.agents.values()].map(({ id, name, task, state, currentAction }) => ({
209
+ id,
210
+ name,
211
+ task,
212
+ state,
213
+ currentAction,
214
+ })),
215
+ fileActivity: [...this.fileActivity.values()],
216
+ notes: this.notes.slice(-100),
217
+ };
218
+ fs.writeFileSync(path.join(dir, 'state.json'), JSON.stringify(state, null, 2));
219
+ }
220
+ catch {
221
+ // best effort only
222
+ }
223
+ }, 500);
224
+ }
225
+ }