@parallel-cli/parallel 0.4.3 → 0.4.5

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/dist/commands.js CHANGED
@@ -1,6 +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';
3
- import { providerReady } from './config.js';
6
+ import { isLocalProvider, isPlaceholderModel, providerNeedsApiKey } from './config.js';
4
7
  import { t } from './i18n.js';
5
8
  // Grouped by intent so /help reads as a story: create agents → steer them →
6
9
  // inspect the session → git safety net → session & config → exit.
@@ -66,6 +69,72 @@ export function matchCommands(input, opts = {}) {
66
69
  function agentList(ctl) {
67
70
  return [...ctl.board.agents.values()].map((a) => a.name).join(', ') || t('m.none');
68
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
+ }
69
138
  /**
70
139
  * Single-agent ergonomics: when the session has exactly ONE agent, commands
71
140
  * that target an agent (/undo, /focus, /pause…) work without naming it.
@@ -78,10 +147,12 @@ function spawnFrom(arg, ctl, ui, images, specialist, mode = 'task') {
78
147
  const p = ctl.sessionProvider();
79
148
  if (!p)
80
149
  return ui.system(t('m.missingProvider'), 'error');
81
- if (!providerReady(p))
150
+ if (providerNeedsApiKey(p) && !p.apiKey)
82
151
  return ui.system(t('m.missingKey', { name: p.name }), 'error');
83
- if (!ctl.session.model && !p.defaultModel && !p.models[0])
152
+ const activeModel = ctl.session.model || p.defaultModel || p.models[0] || '';
153
+ if (isPlaceholderModel(activeModel)) {
84
154
  return ui.system(t('m.missingModel', { name: p.name }), 'error');
155
+ }
85
156
  // optional --model=xxx flag
86
157
  let model;
87
158
  let task = arg;
@@ -129,8 +200,8 @@ export function executeInput(raw, ctl, ui, images) {
129
200
  return ui.system(t('m.usageAt'), 'warn');
130
201
  const [, target, content] = m;
131
202
  if (target.toLowerCase() === 'all') {
132
- ctl.broadcast(content);
133
- ui.system(t('m.broadcast'), 'ok');
203
+ const n = ctl.broadcast(content);
204
+ ui.system(t('m.broadcast', { n }), n > 0 ? 'ok' : 'warn');
134
205
  }
135
206
  else if (ctl.sendToAgent(target, content)) {
136
207
  ui.system(t('m.sent', { target }), 'ok');
@@ -217,10 +288,18 @@ export function executeInput(raw, ctl, ui, images) {
217
288
  case '/commit': {
218
289
  // Commit the files touched by one agent (or all) — staged by explicit path.
219
290
  const [target0, ...msg] = rest;
220
- 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();
221
300
  if (!target)
222
301
  return ui.system(t('m.usageCommit'), 'warn');
223
- const r = ctl.commitFor(target, msg.join(' ').trim() || undefined);
302
+ const r = ctl.commitFor(target, message || undefined);
224
303
  if (r.ok)
225
304
  return ui.system(t('m.committed', { name: target, files: String(r.files) }), 'ok');
226
305
  if (r.reason === 'not-found')
@@ -249,15 +328,19 @@ export function executeInput(raw, ctl, ui, images) {
249
328
  ui.setView('sessions');
250
329
  return;
251
330
  }
331
+ const force = rest.includes('--force');
332
+ const selector = rest.filter((part) => part !== '--force').join(' ').trim();
252
333
  const sessions = Controller.listSessions(ctl.projectRoot);
253
334
  if (sessions.length === 0)
254
335
  return ui.system(t('m.usageSession'), 'warn');
255
- const idx = arg.toLowerCase() === 'latest' ? 0 : Number.parseInt(arg, 10) - 1;
336
+ const idx = selector.toLowerCase() === 'latest' ? 0 : Number.parseInt(selector, 10) - 1;
256
337
  const session = sessions[idx];
257
338
  if (!session)
258
339
  return ui.system(t('m.usageSession'), 'warn');
340
+ if (ctl.hasRunningAgents() && !force)
341
+ return ui.system(t('m.sessionActive'), 'warn');
259
342
  ctl.loadSession(session.data);
260
- 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');
261
344
  return;
262
345
  }
263
346
  case '/restore': {
@@ -267,6 +350,8 @@ export function executeInput(raw, ctl, ui, images) {
267
350
  if (!ctl.loadedSession)
268
351
  return ui.system(t('m.usageSession'), 'warn');
269
352
  const res = ctl.respawnAgent(arg);
353
+ if (res === 'no-agent')
354
+ return ui.system(t('m.noRestoredAgent', { name: arg }), 'error');
270
355
  if (res === 'no-conversation')
271
356
  return ui.system(t('m.noConversation', { name: arg }), 'error');
272
357
  if (!res)
@@ -315,14 +400,7 @@ export function executeInput(raw, ctl, ui, images) {
315
400
  return;
316
401
  }
317
402
  case '/doctor': {
318
- const p = ctl.sessionProvider();
319
- if (!p)
320
- return ui.system(t('m.missingProvider'), 'error');
321
- if (!providerReady(p))
322
- return ui.system(t('m.missingKey', { name: p.name }), 'error');
323
- if (!ctl.session.model && !p.defaultModel && !p.models[0])
324
- return ui.system(t('m.missingModel', { name: p.name }), 'error');
325
- ui.system(t('m.doctorOk', { pm: `${p.name}:${ctl.session.model || p.defaultModel || p.models[0]}` }), 'ok');
403
+ void doctorReport(ctl, ui);
326
404
  return;
327
405
  }
328
406
  case '/cost':
@@ -476,9 +554,17 @@ export function executeInput(raw, ctl, ui, images) {
476
554
  ui.setView('settings-session');
477
555
  return;
478
556
  case '/project':
479
- ui.openProject?.(arg || undefined);
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
+ }
480
564
  return;
481
565
  case '/wizard':
566
+ if (ctl.hasRunningAgents() && arg !== '--force')
567
+ return ui.system(t('m.wizardActive'), 'warn');
482
568
  ui.openWizard?.();
483
569
  return;
484
570
  // SESSION-only approvals & sound (global defaults editable in /settings).
package/dist/config.js CHANGED
@@ -280,8 +280,38 @@ export function isLocalProvider(p) {
280
280
  export function providerNeedsApiKey(p) {
281
281
  return p.requiresApiKey !== false && !isLocalProvider(p);
282
282
  }
283
+ export function isPlaceholderModel(model) {
284
+ return !model.trim() || /^your-model-here$/i.test(model.trim());
285
+ }
286
+ export function providerHasUsableModel(p) {
287
+ return !isPlaceholderModel(p.defaultModel || p.models[0] || '');
288
+ }
283
289
  export function providerReady(p) {
284
- return !providerNeedsApiKey(p) || p.apiKey.trim().length > 0;
290
+ return (!providerNeedsApiKey(p) || p.apiKey.trim().length > 0) && providerHasUsableModel(p);
291
+ }
292
+ export async function detectProviderModels(provider, timeoutMs = 2000) {
293
+ let timeout;
294
+ try {
295
+ const controller = new AbortController();
296
+ timeout = setTimeout(() => controller.abort(), timeoutMs);
297
+ const resp = await fetch(provider.baseUrl.replace(/\/+$/, '') + '/models', { signal: controller.signal });
298
+ if (!resp.ok)
299
+ return null;
300
+ const data = (await resp.json());
301
+ const models = [
302
+ ...(data.data?.map((m) => m.id || m.name).filter(Boolean) ?? []),
303
+ ...(data.models?.map((m) => m.name).filter(Boolean) ?? []),
304
+ ];
305
+ const unique = [...new Set(models.filter((m) => !isPlaceholderModel(m)))];
306
+ return unique.length > 0 ? { models: unique, defaultModel: unique[0] } : null;
307
+ }
308
+ catch {
309
+ return null;
310
+ }
311
+ finally {
312
+ if (timeout)
313
+ clearTimeout(timeout);
314
+ }
285
315
  }
286
316
  export function getProvider(cfg, name) {
287
317
  const n = (name ?? cfg.defaultProvider).toLowerCase();
@@ -72,6 +72,7 @@ export class Controller extends EventEmitter {
72
72
  conversationFiles = new Map();
73
73
  /** The session restored at startup (source of /restore conversations). */
74
74
  loadedSession = null;
75
+ sessionOnlyProvider = null;
75
76
  constructor(config, projectRoot) {
76
77
  super();
77
78
  this.config = config;
@@ -108,6 +109,10 @@ export class Controller extends EventEmitter {
108
109
  // ---------- providers / models ----------
109
110
  /** Provider used by the current session (falls back to the global default). */
110
111
  sessionProvider() {
112
+ if (this.sessionOnlyProvider &&
113
+ this.session.providerName.toLowerCase() === this.sessionOnlyProvider.name.toLowerCase()) {
114
+ return this.sessionOnlyProvider;
115
+ }
111
116
  return getProvider(this.config, this.session.providerName || undefined);
112
117
  }
113
118
  /** Resolve "model" or "provider:model" against the configured providers. */
@@ -115,7 +120,10 @@ export class Controller extends EventEmitter {
115
120
  const trimmed = spec.trim();
116
121
  const sep = trimmed.indexOf(':');
117
122
  if (sep > 0) {
118
- const provider = getProvider(this.config, trimmed.slice(0, sep).trim());
123
+ const left = trimmed.slice(0, sep).trim();
124
+ const provider = this.sessionOnlyProvider && this.sessionOnlyProvider.name.toLowerCase() === left.toLowerCase()
125
+ ? this.sessionOnlyProvider
126
+ : getProvider(this.config, left);
119
127
  if (provider)
120
128
  return { provider, model: trimmed.slice(sep + 1).trim() };
121
129
  }
@@ -231,7 +239,7 @@ export class Controller extends EventEmitter {
231
239
  return;
232
240
  const [q] = this.questions.splice(idx, 1);
233
241
  this.board.log('', 'system', auto ? t('m.qAuto', { name: q.agentName, answer }) : t('m.qAnswered', { name: q.agentName, answer }));
234
- q.resolve(answer);
242
+ q.resolve({ answer, auto });
235
243
  this.emit('update');
236
244
  }
237
245
  // ---------- agents ----------
@@ -380,9 +388,10 @@ export class Controller extends EventEmitter {
380
388
  * memory intact instead of starting from scratch.
381
389
  */
382
390
  respawnAgent(name) {
383
- const sa = this.loadedSession?.agents.find((a) => a.name.toLowerCase() === name.toLowerCase());
391
+ const ref = name.toLowerCase();
392
+ const sa = this.loadedSession?.agents.find((a) => a.name.toLowerCase() === ref || a.alias?.toLowerCase() === ref || a.id?.toLowerCase() === ref);
384
393
  if (!sa)
385
- return 'no-conversation';
394
+ return 'no-agent';
386
395
  if (!sa.conversation || !fs.existsSync(sa.conversation))
387
396
  return 'no-conversation';
388
397
  let history;
@@ -398,7 +407,8 @@ export class Controller extends EventEmitter {
398
407
  }
399
408
  if (history.length === 0)
400
409
  return 'no-conversation';
401
- return this.spawnAgent(sa.task, sa.name, sa.model, undefined, undefined, history, sa.mode ?? 'task');
410
+ const modelSpec = sa.model ? (sa.providerName ? `${sa.providerName}:${sa.model}` : sa.model) : undefined;
411
+ return this.spawnAgent(sa.task, sa.name, modelSpec, undefined, sa.specialist, history, sa.mode ?? 'task');
402
412
  }
403
413
  pauseAgent(name) {
404
414
  const a = this.findAgent(name);
@@ -427,7 +437,7 @@ export class Controller extends EventEmitter {
427
437
  for (const req of this.approvals.splice(0))
428
438
  req.resolve(false);
429
439
  for (const q of this.questions.splice(0))
430
- q.resolve(q.options[q.recommended] ?? '');
440
+ q.resolve({ answer: q.options[q.recommended] ?? '', auto: true });
431
441
  }
432
442
  sendToAgent(name, content) {
433
443
  const a = this.findAgent(name);
@@ -437,7 +447,21 @@ export class Controller extends EventEmitter {
437
447
  return true;
438
448
  }
439
449
  broadcast(content) {
440
- this.board.addNote('user', 'all', content);
450
+ const stamp = content.trim();
451
+ const recent = [...this.board.notes]
452
+ .reverse()
453
+ .find((n) => n.from === 'user' && n.to === 'all' && Date.now() - n.ts < 1500);
454
+ if (stamp && recent?.content !== stamp)
455
+ this.board.addNote('user', 'all', stamp);
456
+ let n = 0;
457
+ for (const [id, agent] of this.agents.entries()) {
458
+ const info = this.board.agents.get(id);
459
+ if (!info || ['done', 'error', 'stopped'].includes(info.state))
460
+ continue;
461
+ agent.instruct(content);
462
+ n++;
463
+ }
464
+ return n;
441
465
  }
442
466
  hasRunningAgents() {
443
467
  return [...this.board.agents.values()].some((a) => ['working', 'thinking', 'listening', 'waiting', 'paused', 'idle'].includes(a.state));
@@ -464,8 +488,10 @@ export class Controller extends EventEmitter {
464
488
  continue;
465
489
  const conflict = this.board.changes.some((c2) => c2.id > c.id && c2.path === c.path && c2.agentId !== info.id);
466
490
  try {
467
- const abs = path.resolve(this.projectRoot, c.path);
468
- if (!abs.startsWith(path.resolve(this.projectRoot)))
491
+ const root = path.resolve(this.projectRoot);
492
+ const abs = path.resolve(root, c.path);
493
+ const rel = path.relative(root, abs);
494
+ if (rel.startsWith('..') || path.isAbsolute(rel))
469
495
  return 'none';
470
496
  fs.writeFileSync(abs, c.before);
471
497
  }
@@ -589,12 +615,20 @@ export class Controller extends EventEmitter {
589
615
  try {
590
616
  const dir = this.sessionsDir();
591
617
  fs.mkdirSync(dir, { recursive: true });
618
+ const providerName = this.sessionProvider()?.name ?? this.session.providerName;
619
+ const trimChange = (c) => ({
620
+ ...c,
621
+ before: c.before.length > 40_000 ? `${c.before.slice(0, 40_000)}\n/* truncated */` : c.before,
622
+ after: c.after.length > 40_000 ? `${c.after.slice(0, 40_000)}\n/* truncated */` : c.after,
623
+ });
592
624
  const data = {
593
625
  savedAt: new Date().toISOString(),
594
626
  name: this.sessionName,
595
627
  projectRoot: this.projectRoot,
596
628
  agents: [...this.board.agents.values()].map((a) => ({
629
+ id: a.id,
597
630
  name: a.name,
631
+ alias: a.alias,
598
632
  task: a.task,
599
633
  mode: a.mode,
600
634
  state: a.state,
@@ -603,10 +637,17 @@ export class Controller extends EventEmitter {
603
637
  tokensIn: a.tokensIn,
604
638
  tokensOut: a.tokensOut,
605
639
  cost: a.cost,
640
+ providerName,
606
641
  model: a.model,
642
+ specialist: a.specialist,
643
+ claims: a.claims,
644
+ ctxPct: a.ctxPct,
607
645
  conversation: this.conversationFiles.get(a.id),
608
646
  })),
609
647
  notes: this.board.notes.slice(-200),
648
+ changes: this.board.changes.slice(-80).map(trimChange),
649
+ fileActivity: [...this.board.fileActivity.values()].slice(-100),
650
+ workMapWarnings: this.board.workMapWarnings.slice(-80),
610
651
  changedFiles: [...new Set(this.board.changes.map((c) => c.path))],
611
652
  };
612
653
  const file = path.join(dir, `session-${this.sessionStamp}.json`);
@@ -630,7 +671,7 @@ export class Controller extends EventEmitter {
630
671
  return { file, data: JSON.parse(fs.readFileSync(file, 'utf8')) };
631
672
  })
632
673
  .sort((a, b) => (a.data.savedAt < b.data.savedAt ? 1 : -1))
633
- .slice(0, 8);
674
+ .slice(0, 20);
634
675
  }
635
676
  catch {
636
677
  return [];
@@ -642,10 +683,13 @@ export class Controller extends EventEmitter {
642
683
  if (data.name)
643
684
  this.sessionName = data.name;
644
685
  const tasks = data.agents.map((a) => `${a.name} [${a.state}] : ${a.task}${a.lastResult ? ` → ${a.lastResult}` : ''}`);
645
- this.board.addNote('system', 'all', `Previous session restored (${data.savedAt}). Past work:\n${tasks.join('\n')}\nFiles changed then: ${data.changedFiles.join(', ') || '(none)'}`);
646
- for (const n of data.notes.slice(-50)) {
647
- this.board.notes.push({ ...n, id: this.board.notes.length + 1 });
648
- }
686
+ this.board.hydrate({
687
+ notes: data.notes ?? [],
688
+ changes: data.changes ?? [],
689
+ fileActivity: data.fileActivity ?? [],
690
+ workMapWarnings: data.workMapWarnings ?? [],
691
+ });
692
+ this.board.addNote('system', 'all', `Previous session restored (${data.savedAt}). Past work:\n${tasks.join('\n')}\nFiles changed then: ${(data.changedFiles ?? []).join(', ') || '(none)'}`);
649
693
  this.board.log('', 'system', t('m.sessionRestored', { date: new Date(data.savedAt).toLocaleString() }));
650
694
  // Financial history: per-agent cost/steps/tokens of the restored session.
651
695
  const withCost = data.agents.filter((a) => a.cost !== undefined || a.tokensIn !== undefined);
@@ -671,11 +715,19 @@ export class Controller extends EventEmitter {
671
715
  const p = getProvider(this.config, name);
672
716
  if (!p)
673
717
  return false;
718
+ this.sessionOnlyProvider = null;
674
719
  this.session.providerName = p.name;
675
720
  this.session.model = p.defaultModel || p.models[0] || '';
676
721
  this.emit('update');
677
722
  return true;
678
723
  }
724
+ setSessionProviderConfig(p) {
725
+ this.sessionOnlyProvider = p;
726
+ this.session.providerName = p.name;
727
+ this.session.model = p.defaultModel || p.models[0] || '';
728
+ this.llmCache.clear();
729
+ this.emit('update');
730
+ }
679
731
  setSessionApprovalMode(mode) {
680
732
  this.session.approvalMode = mode;
681
733
  this.emit('update');
@@ -687,6 +739,8 @@ export class Controller extends EventEmitter {
687
739
  // ---------- GLOBAL settings (/settings) — persisted ----------
688
740
  saveProvider(p) {
689
741
  upsertProvider(this.config, p);
742
+ if (this.sessionOnlyProvider?.name.toLowerCase() === p.name.toLowerCase())
743
+ this.sessionOnlyProvider = null;
690
744
  this.llmCache.clear();
691
745
  // if the session points at this provider, refresh its view
692
746
  if (this.session.providerName.toLowerCase() === p.name.toLowerCase()) {
@@ -17,6 +17,7 @@ export class Blackboard extends EventEmitter {
17
17
  notes = [];
18
18
  changes = [];
19
19
  logs = [];
20
+ workMapWarnings = [];
20
21
  noteSeq = 0;
21
22
  changeSeq = 0;
22
23
  logSeq = 0;
@@ -42,6 +43,8 @@ export class Blackboard extends EventEmitter {
42
43
  if (!a)
43
44
  return;
44
45
  Object.assign(a, patch);
46
+ if ('claims' in patch)
47
+ this.recomputeWorkMap();
45
48
  this.touch();
46
49
  }
47
50
  setAgentState(id, state, action) {
@@ -142,11 +145,65 @@ export class Blackboard extends EventEmitter {
142
145
  this.conflictCounts.set(relPath, n);
143
146
  if (n === 3)
144
147
  this.emit('agent-event', { type: 'conflict', path: relPath });
148
+ this.upsertWorkWarning({
149
+ id: `conflict:${relPath}`,
150
+ level: n >= 3 ? 'conflict' : 'warn',
151
+ title: 'Repeated edit conflict',
152
+ detail: `${relPath} has ${n} recorded co-edit collision${n === 1 ? '' : 's'}. Coordinate before touching it again.`,
153
+ paths: [relPath],
154
+ agentNames: [],
155
+ ts: Date.now(),
156
+ count: n,
157
+ });
145
158
  return n;
146
159
  }
147
160
  lastChangeId() {
148
161
  return this.changes.length > 0 ? this.changes[this.changes.length - 1].id : 0;
149
162
  }
163
+ static normClaim(p) {
164
+ return p.trim().replace(/\\/g, '/').replace(/^\.\/+/, '').replace(/\/+$/, '');
165
+ }
166
+ static overlaps(a, b) {
167
+ const x = Blackboard.normClaim(a);
168
+ const y = Blackboard.normClaim(b);
169
+ if (!x || !y)
170
+ return false;
171
+ return x === y || x.startsWith(`${y}/`) || y.startsWith(`${x}/`);
172
+ }
173
+ upsertWorkWarning(warning) {
174
+ const idx = this.workMapWarnings.findIndex((w) => w.id === warning.id);
175
+ if (idx >= 0)
176
+ this.workMapWarnings[idx] = warning;
177
+ else
178
+ this.workMapWarnings.push(warning);
179
+ if (this.workMapWarnings.length > 100)
180
+ this.workMapWarnings.splice(0, this.workMapWarnings.length - 100);
181
+ }
182
+ recomputeWorkMap() {
183
+ const agents = [...this.agents.values()].filter((a) => a.claims && a.claims.length > 0);
184
+ const warnings = [];
185
+ for (let i = 0; i < agents.length; i++) {
186
+ for (let j = i + 1; j < agents.length; j++) {
187
+ const a = agents[i];
188
+ const b = agents[j];
189
+ const paths = (a.claims ?? []).filter((left) => (b.claims ?? []).some((right) => Blackboard.overlaps(left, right)));
190
+ if (paths.length === 0)
191
+ continue;
192
+ warnings.push({
193
+ id: `overlap:${[a.id, b.id].sort().join(':')}`,
194
+ level: 'warn',
195
+ title: 'Overlapping work areas',
196
+ detail: `${a.name} and ${b.name} both declared ${paths.join(', ')}.`,
197
+ paths,
198
+ agentNames: [a.name, b.name],
199
+ ts: Date.now(),
200
+ });
201
+ }
202
+ }
203
+ const conflictWarnings = this.workMapWarnings.filter((w) => w.id.startsWith('conflict:')).slice(-20);
204
+ this.workMapWarnings = [...conflictWarnings, ...warnings].slice(-100);
205
+ return this.workMapWarnings;
206
+ }
150
207
  // ---------- logs ----------
151
208
  log(agentId, kind, text) {
152
209
  this.logs.push({ agentId, kind, text, ts: Date.now(), seq: ++this.logSeq });
@@ -189,6 +246,13 @@ export class Blackboard extends EventEmitter {
189
246
  lines.push(` • ${act.path} — ${mine ? 'you' : act.agentName} (${act.op}, ${age}s ago)`);
190
247
  }
191
248
  }
249
+ const warnings = this.workMapWarnings.filter((w) => w.level !== 'info').slice(-5);
250
+ if (warnings.length > 0) {
251
+ lines.push('Work map warnings (advisory, do not block):');
252
+ for (const w of warnings) {
253
+ lines.push(` • ${w.title}: ${w.detail}`);
254
+ }
255
+ }
192
256
  if (me)
193
257
  lines.push(`Reminder — your task: ${me.task}`);
194
258
  lines.push('=== END OF REAL-TIME STATE ===');
@@ -214,6 +278,8 @@ export class Blackboard extends EventEmitter {
214
278
  })),
215
279
  fileActivity: [...this.fileActivity.values()],
216
280
  notes: this.notes.slice(-100),
281
+ changes: this.changes.slice(-50),
282
+ workMapWarnings: this.workMapWarnings.slice(-50),
217
283
  };
218
284
  fs.writeFileSync(path.join(dir, 'state.json'), JSON.stringify(state, null, 2));
219
285
  }
@@ -222,4 +288,13 @@ export class Blackboard extends EventEmitter {
222
288
  }
223
289
  }, 500);
224
290
  }
291
+ hydrate(data) {
292
+ this.notes = [...(data.notes ?? [])].sort((a, b) => a.id - b.id);
293
+ this.changes = [...(data.changes ?? [])].sort((a, b) => a.id - b.id);
294
+ this.fileActivity = new Map((data.fileActivity ?? []).map((a) => [a.path, a]));
295
+ this.workMapWarnings = [...(data.workMapWarnings ?? [])].sort((a, b) => a.ts - b.ts);
296
+ this.noteSeq = this.notes.reduce((max, n) => Math.max(max, n.id), 0);
297
+ this.changeSeq = this.changes.reduce((max, c) => Math.max(max, c.id), 0);
298
+ this.touch();
299
+ }
225
300
  }