@parallel-cli/parallel 0.4.6 → 0.4.8

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.
@@ -45,6 +45,24 @@ export const TOOL_DEFINITIONS = [
45
45
  },
46
46
  },
47
47
  },
48
+ {
49
+ type: 'function',
50
+ function: {
51
+ name: 'read_many',
52
+ description: 'Read several known files in one tool call. Use this instead of many sequential read_file calls when the files are independent. Max 8 files.',
53
+ parameters: {
54
+ type: 'object',
55
+ properties: {
56
+ paths: {
57
+ type: 'array',
58
+ items: { type: 'string' },
59
+ description: 'Relative file paths to read',
60
+ },
61
+ },
62
+ required: ['paths'],
63
+ },
64
+ },
65
+ },
48
66
  {
49
67
  type: 'function',
50
68
  function: {
@@ -91,6 +109,28 @@ export const TOOL_DEFINITIONS = [
91
109
  },
92
110
  },
93
111
  },
112
+ {
113
+ type: 'function',
114
+ function: {
115
+ name: 'inspect_project',
116
+ description: 'Batch read-only inspection: list files under paths and/or run several regex searches in one call. Prefer this over cascades of grep/head/tail/wc/awk for exploratory checks.',
117
+ parameters: {
118
+ type: 'object',
119
+ properties: {
120
+ paths: {
121
+ type: 'array',
122
+ items: { type: 'string' },
123
+ description: 'Folders or files to inspect, default ["."], max 5',
124
+ },
125
+ patterns: {
126
+ type: 'array',
127
+ items: { type: 'string' },
128
+ description: 'Regex patterns to search, max 5',
129
+ },
130
+ },
131
+ },
132
+ },
133
+ },
94
134
  {
95
135
  type: 'function',
96
136
  function: {
@@ -134,6 +174,32 @@ export const TOOL_DEFINITIONS = [
134
174
  },
135
175
  },
136
176
  },
177
+ {
178
+ type: 'function',
179
+ function: {
180
+ name: 'update_steps',
181
+ description: 'Update the visible Cursor-style task checklist. At task start create 3-6 concrete steps; during work keep exactly one active step and mark completed steps done.',
182
+ parameters: {
183
+ type: 'object',
184
+ properties: {
185
+ steps: {
186
+ type: 'array',
187
+ minItems: 1,
188
+ maxItems: 6,
189
+ items: {
190
+ type: 'object',
191
+ properties: {
192
+ text: { type: 'string' },
193
+ status: { type: 'string', enum: ['pending', 'active', 'done'] },
194
+ },
195
+ required: ['text', 'status'],
196
+ },
197
+ },
198
+ },
199
+ required: ['steps'],
200
+ },
201
+ },
202
+ },
137
203
  {
138
204
  type: 'function',
139
205
  function: {
@@ -269,6 +335,22 @@ export class ToolExecutor {
269
335
  relOf(p) {
270
336
  return path.relative(this.projectRoot, this.resolve(p)) || '.';
271
337
  }
338
+ rememberRead(relPath, content) {
339
+ this.lastRead.set(relPath, { content, revision: this.board.fileRevision(relPath) });
340
+ }
341
+ adaptationMessage(relPath, seen, current, verb = 'was modified') {
342
+ this.board.recordConflict(relPath);
343
+ const who = this.board.fileActivity.get(relPath);
344
+ const author = who && who.agentId !== this.agentId ? who.agentName : 'another agent or a shell command';
345
+ const patch = Diff.createPatch(relPath, seen.content, current, `your read version r${seen.revision}`, `current version r${this.board.fileRevision(relPath)}`, {
346
+ context: 2,
347
+ });
348
+ const excerpt = patch.split('\n').slice(4, 40).join('\n');
349
+ return (`REAL-TIME ADAPTATION: ${relPath} ${verb} by ${author} after your last read. ` +
350
+ `Here are THEIR changes (to KEEP, do not erase them):\n${excerpt}\n` +
351
+ `Call read_file on ${relPath} before editing or rewriting it, then merge your change on top of the current version. ` +
352
+ `If your work conflicts, send them a note.`);
353
+ }
272
354
  snapshotProject() {
273
355
  const snapshot = new Map();
274
356
  const walk = (dir, depth) => {
@@ -333,12 +415,16 @@ export class ToolExecutor {
333
415
  return this.listFiles(args?.path ?? '.');
334
416
  case 'read_file':
335
417
  return this.readFile(args.path);
418
+ case 'read_many':
419
+ return this.readMany(args.paths);
336
420
  case 'write_file':
337
421
  return this.writeFile(args.path, args.content);
338
422
  case 'edit_file':
339
423
  return this.editFile(args.path, args.old_string, args.new_string);
340
424
  case 'search':
341
425
  return this.search(args.pattern, args?.path ?? '.');
426
+ case 'inspect_project':
427
+ return this.inspectProject(args);
342
428
  case 'run_command':
343
429
  return await this.runCommand(args.command);
344
430
  case 'post_note':
@@ -347,6 +433,8 @@ export class ToolExecutor {
347
433
  case 'update_status':
348
434
  this.board.updateAgent(this.agentId, { currentAction: String(args.status ?? '') });
349
435
  return 'Status updated, visible to the other agents.';
436
+ case 'update_steps':
437
+ return this.updateSteps(args);
350
438
  case 'ask_user':
351
439
  return await this.askUser(args);
352
440
  case 'load_skill':
@@ -472,6 +560,24 @@ export class ToolExecutor {
472
560
  this.board.log(this.agentId, 'tool', `🧩 skill loaded: ${skill.name}`);
473
561
  return `[SKILL: ${skill.name}] (${skill.scope})\n${skill.body}\n[END SKILL — follow these instructions for the rest of your task]`;
474
562
  }
563
+ updateSteps(args) {
564
+ const raw = Array.isArray(args.steps) ? args.steps : [];
565
+ const steps = raw
566
+ .slice(0, 6)
567
+ .map((s) => ({
568
+ text: String(s?.text ?? '').replace(/\s+/g, ' ').trim().slice(0, 100),
569
+ status: s?.status === 'done' || s?.status === 'active' ? s.status : 'pending',
570
+ }))
571
+ .filter((s) => s.text.length > 0);
572
+ if (steps.length === 0)
573
+ return 'ERROR: update_steps needs 1-6 non-empty steps.';
574
+ const activeCount = steps.filter((s) => s.status === 'active').length;
575
+ if (activeCount > 1)
576
+ return 'ERROR: update_steps must have at most one active step.';
577
+ this.board.updateAgent(this.agentId, { progressSteps: steps });
578
+ this.board.log(this.agentId, 'tool', `☑ steps ${steps.filter((s) => s.status === 'done').length}/${steps.length}`);
579
+ return `Visible steps updated (${steps.length} step${steps.length === 1 ? '' : 's'}).`;
580
+ }
475
581
  listFiles(rel) {
476
582
  const root = this.resolve(rel);
477
583
  const out = [];
@@ -511,14 +617,41 @@ export class ToolExecutor {
511
617
  }
512
618
  readFile(rel) {
513
619
  const abs = this.resolve(rel);
620
+ const relPath = this.relOf(rel);
514
621
  const content = fs.readFileSync(abs, 'utf8');
515
- this.lastRead.set(this.relOf(rel), content);
622
+ this.rememberRead(relPath, content);
623
+ this.board.resolveConflict(relPath);
516
624
  const lines = content.split('\n');
517
625
  const numbered = lines.map((l, i) => `${String(i + 1).padStart(4)}|${l}`).join('\n');
518
626
  return numbered.length > MAX_OUTPUT
519
627
  ? numbered.slice(0, MAX_OUTPUT) + `\n... (truncated, ${lines.length} lines total)`
520
628
  : numbered;
521
629
  }
630
+ readMany(paths) {
631
+ const relPaths = Array.isArray(paths) ? paths.map((p) => String(p)).slice(0, 8) : [];
632
+ if (relPaths.length === 0)
633
+ return 'ERROR: read_many needs 1-8 paths.';
634
+ const chunks = [];
635
+ for (const rel of relPaths) {
636
+ try {
637
+ const abs = this.resolve(rel);
638
+ const relPath = this.relOf(rel);
639
+ const content = fs.readFileSync(abs, 'utf8');
640
+ this.rememberRead(relPath, content);
641
+ this.board.resolveConflict(relPath);
642
+ const lines = content.split('\n');
643
+ const numbered = lines.map((l, i) => `${String(i + 1).padStart(4)}|${l}`).join('\n');
644
+ const body = numbered.length > Math.floor(MAX_OUTPUT / relPaths.length)
645
+ ? numbered.slice(0, Math.floor(MAX_OUTPUT / relPaths.length)) + `\n... (truncated, ${lines.length} lines total)`
646
+ : numbered;
647
+ chunks.push(`--- ${relPath} (${lines.length} lines) ---\n${body}`);
648
+ }
649
+ catch (err) {
650
+ chunks.push(`--- ${rel} ---\nERROR: ${err?.message ?? String(err)}`);
651
+ }
652
+ }
653
+ return chunks.join('\n\n');
654
+ }
522
655
  /**
523
656
  * Adaptive co-editing: writing is NEVER blocked by another agent.
524
657
  * But if the file changed under you since your last read, you first get
@@ -529,33 +662,23 @@ export class ToolExecutor {
529
662
  const abs = this.resolve(rel);
530
663
  const exists = fs.existsSync(abs);
531
664
  const seen = this.lastRead.get(relPath);
665
+ const current = exists ? fs.readFileSync(abs, 'utf8') : '';
532
666
  if (exists) {
533
- const current = fs.readFileSync(abs, 'utf8');
534
667
  if (seen === undefined) {
535
668
  const who = this.board.fileActivity.get(relPath);
536
669
  return (`WARNING: ${relPath} already exists${who && who.agentId !== this.agentId ? ` and agent ${who.agentName} is working on it` : ''}. ` +
537
670
  `Read it first (read_file) so you don't erase any work, then rewrite while integrating what exists.`);
538
671
  }
539
- if (current !== seen) {
540
- this.lastRead.set(relPath, current); // sync view so next write passes
541
- this.board.recordConflict(relPath); // repeated collisions escalate to the user
542
- const who = this.board.fileActivity.get(relPath);
543
- const author = who && who.agentId !== this.agentId ? who.agentName : 'another agent';
544
- const patch = Diff.createPatch(relPath, seen, current, 'your read version', 'current version', {
545
- context: 2,
546
- });
547
- const excerpt = patch.split('\n').slice(4, 40).join('\n');
548
- return (`REAL-TIME ADAPTATION: ${relPath} was modified by ${author} while you were working. ` +
549
- `Here are THEIR changes (to KEEP, do not erase them):\n${excerpt}\n` +
550
- `Your view is now synchronized. Rewrite the file by MERGING your changes with theirs ` +
551
- `(or use edit_file for targeted changes). If your work conflicts, send them a note.`);
672
+ if (current !== seen.content || this.board.fileRevision(relPath) !== seen.revision) {
673
+ return this.adaptationMessage(relPath, seen, current);
552
674
  }
553
675
  }
554
676
  fs.mkdirSync(path.dirname(abs), { recursive: true });
555
677
  fs.writeFileSync(abs, content);
556
- this.lastRead.set(relPath, content);
557
- const before = exists ? (seen ?? '') : '';
678
+ const before = exists ? current : '';
558
679
  this.board.addChange(this.agentId, relPath, before, content);
680
+ this.rememberRead(relPath, content);
681
+ this.board.resolveConflict(relPath);
559
682
  this.board.recordActivity(relPath, this.agentId, 'write');
560
683
  this.board.log(this.agentId, 'tool', `✏ write ${relPath} (${content.length}B)`);
561
684
  return `File written: ${relPath} (${content.split('\n').length} lines). The other agents see your diff in real time.`;
@@ -564,11 +687,19 @@ export class ToolExecutor {
564
687
  const relPath = this.relOf(rel);
565
688
  const abs = this.resolve(rel);
566
689
  const before = fs.readFileSync(abs, 'utf8');
690
+ const seen = this.lastRead.get(relPath);
691
+ if (seen === undefined) {
692
+ const who = this.board.fileActivity.get(relPath);
693
+ return (`WARNING: ${relPath} has not been read by you yet${who && who.agentId !== this.agentId ? ` and agent ${who.agentName} touched it recently` : ''}. ` +
694
+ `Call read_file first so your targeted edit is based on the current version.`);
695
+ }
696
+ if (before !== seen.content || this.board.fileRevision(relPath) !== seen.revision) {
697
+ return this.adaptationMessage(relPath, seen, before, 'changed');
698
+ }
567
699
  const count = before.split(oldStr).length - 1;
568
700
  if (count === 0) {
569
- const seen = this.lastRead.get(relPath);
570
701
  const who = this.board.fileActivity.get(relPath);
571
- const collided = seen !== undefined && seen !== before && who && who.agentId !== this.agentId;
702
+ const collided = who && who.agentId !== this.agentId;
572
703
  if (collided)
573
704
  this.board.recordConflict(relPath);
574
705
  const hint = collided
@@ -581,8 +712,9 @@ export class ToolExecutor {
581
712
  }
582
713
  const after = before.replace(oldStr, newStr);
583
714
  fs.writeFileSync(abs, after);
584
- this.lastRead.set(relPath, after);
585
715
  this.board.addChange(this.agentId, relPath, before, after);
716
+ this.rememberRead(relPath, after);
717
+ this.board.resolveConflict(relPath);
586
718
  this.board.recordActivity(relPath, this.agentId, 'edit');
587
719
  this.board.log(this.agentId, 'tool', `✏ edit ${relPath}`);
588
720
  return `File modified: ${relPath}. The other agents see your diff in real time.`;
@@ -637,6 +769,72 @@ export class ToolExecutor {
637
769
  walk(root, 0);
638
770
  return results.length > 0 ? results.join('\n') : 'No results.';
639
771
  }
772
+ inspectProject(args) {
773
+ const paths = Array.isArray(args?.paths) && args.paths.length > 0 ? args.paths.map((p) => String(p)).slice(0, 5) : ['.'];
774
+ const patterns = Array.isArray(args?.patterns) ? args.patterns.map((p) => String(p)).filter(Boolean).slice(0, 5) : [];
775
+ const files = [];
776
+ const matches = [];
777
+ const regexes = [];
778
+ for (const raw of patterns) {
779
+ try {
780
+ regexes.push({ raw, re: new RegExp(raw) });
781
+ }
782
+ catch {
783
+ matches.push(`INVALID REGEX: ${raw}`);
784
+ }
785
+ }
786
+ const visit = (full, depth) => {
787
+ if (depth > 5 || files.length > 400 || matches.length > 200)
788
+ return;
789
+ let stat;
790
+ try {
791
+ stat = fs.statSync(full);
792
+ }
793
+ catch {
794
+ return;
795
+ }
796
+ if (stat.isDirectory()) {
797
+ let entries;
798
+ try {
799
+ entries = fs.readdirSync(full, { withFileTypes: true });
800
+ }
801
+ catch {
802
+ return;
803
+ }
804
+ for (const e of entries) {
805
+ if (IGNORED.has(e.name) || e.name.startsWith('.git'))
806
+ continue;
807
+ visit(path.join(full, e.name), depth + 1);
808
+ }
809
+ return;
810
+ }
811
+ if (!stat.isFile() || stat.size > 1_000_000)
812
+ return;
813
+ const relPath = path.relative(this.projectRoot, full);
814
+ files.push(`${relPath} (${stat.size}B)`);
815
+ if (regexes.length === 0)
816
+ return;
817
+ let content = '';
818
+ try {
819
+ content = fs.readFileSync(full, 'utf8');
820
+ }
821
+ catch {
822
+ return;
823
+ }
824
+ const lines = content.split('\n');
825
+ for (let i = 0; i < lines.length && matches.length <= 200; i++) {
826
+ for (const { raw, re } of regexes) {
827
+ if (re.test(lines[i]))
828
+ matches.push(`${raw} :: ${relPath}:${i + 1}: ${lines[i].trim().slice(0, 140)}`);
829
+ }
830
+ }
831
+ };
832
+ for (const rel of paths)
833
+ visit(this.resolve(rel), 0);
834
+ const fileBlock = files.length > 0 ? files.slice(0, 80).join('\n') : '(no files)';
835
+ const matchBlock = patterns.length > 0 ? (matches.length > 0 ? matches.slice(0, 120).join('\n') : 'No matches.') : '(no patterns requested)';
836
+ return `FILES (${files.length} found, first ${Math.min(files.length, 80)} shown)\n${fileBlock}\n\nMATCHES\n${matchBlock}`;
837
+ }
640
838
  async runCommand(command) {
641
839
  this.board.setAgentState(this.agentId, 'waiting', `approval: ${command.slice(0, 60)}`);
642
840
  const approved = await this.requestApproval(this.agentId, command);
package/dist/commands.js CHANGED
@@ -12,6 +12,7 @@ export const COMMANDS = [
12
12
  { name: '/ask', args: '[Name:] <question> [--model=m]', descKey: 'cmd.ask', group: 'modes', aliases: ['/a'] },
13
13
  { name: '/task', args: '[Name:] <task> [--model=m] [#skill]', descKey: 'cmd.task', group: 'modes', aliases: ['/t'] },
14
14
  { name: '/plan', args: '[Name:] <task> [--model=m]', descKey: 'cmd.plan', group: 'modes', aliases: ['/p'] },
15
+ { name: '/review', args: '[agent|all] [prompt]', descKey: 'cmd.review', group: 'modes' },
15
16
  { name: '/issue', args: '<n>', descKey: 'cmd.issue', group: 'git' },
16
17
  { name: '/specialist', args: '<name> <task> | new <name> [global]', descKey: 'cmd.specialist', group: 'modes' },
17
18
  { name: '/specialists', args: '', descKey: 'cmd.specialists', group: 'views' },
@@ -65,9 +66,11 @@ const COMMAND_PALETTE_PRIORITY = [
65
66
  '/ask',
66
67
  '/task',
67
68
  '/plan',
69
+ '/review',
68
70
  '/send',
69
71
  '/focus',
70
72
  '/attach',
73
+ '/stop',
71
74
  '/agents',
72
75
  '/board',
73
76
  '/diff',
@@ -174,6 +177,37 @@ function soloAgent(ctl) {
174
177
  const list = [...ctl.board.agents.values()];
175
178
  return list.length === 1 ? list[0].name : null;
176
179
  }
180
+ export function buildReviewPrompt(targetLabel, customPrompt, agents, changes, warnings) {
181
+ const files = [...new Set(changes.map((c) => c.path))].slice(0, 30);
182
+ const agentLines = agents.map((a) => `- ${a.name}${a.alias !== a.name ? ` (${a.alias})` : ''} [${a.state}] ${a.task}`).join('\n') || '- no target agent';
183
+ const fileLines = files.map((f) => `- ${f}`).join('\n') || '- no tracked file changes yet; inspect git status/read relevant files';
184
+ const warningLines = warnings.map((w) => `- ${w.level.toUpperCase()}: ${w.title} (${w.paths.join(', ') || 'no path'}) ${w.detail}`).join('\n') || '- none';
185
+ return `Review target: ${targetLabel}
186
+ ${customPrompt ? `Extra reviewer instruction: ${customPrompt}\n` : ''}
187
+ You are a lightweight reviewer running in ask mode. Do not edit files and do not gate the whole session.
188
+
189
+ Target agents:
190
+ ${agentLines}
191
+
192
+ Tracked files to inspect:
193
+ ${fileLines}
194
+
195
+ Coordination warnings:
196
+ ${warningLines}
197
+
198
+ Review the current working tree and recent coordination context. Inspect the files that matter before deciding. Focus on bugs, regressions, broken contracts between agents, missing validation, and unsafe concurrent edits.
199
+
200
+ Return exactly this structure:
201
+ Verdict: APPROVE | REVISE | BLOCK
202
+ Risks:
203
+ - concrete risk or "none"
204
+ Tests to run:
205
+ - exact command or manual check
206
+ Files to inspect:
207
+ - path and why
208
+ Notes:
209
+ - short reviewer guidance for the user and agents`;
210
+ }
177
211
  function spawnFrom(arg, ctl, ui, images, specialist, mode = 'task') {
178
212
  const p = ctl.sessionProvider();
179
213
  if (!p)
@@ -287,6 +321,25 @@ export function executeInput(raw, ctl, ui, images) {
287
321
  spawnFrom(arg, ctl, ui, images, undefined, 'plan');
288
322
  return;
289
323
  }
324
+ case '/review': {
325
+ const [maybeTarget, ...promptParts] = rest;
326
+ const targetInfo = maybeTarget && maybeTarget.toLowerCase() !== 'all' ? ctl.board.getAgentByName(maybeTarget) : undefined;
327
+ const hasExplicitTarget = Boolean(maybeTarget && (maybeTarget.toLowerCase() === 'all' || targetInfo));
328
+ const target = hasExplicitTarget && maybeTarget ? maybeTarget : 'all';
329
+ const customPrompt = hasExplicitTarget ? promptParts.join(' ').trim() : arg;
330
+ if (target !== 'all' && !targetInfo)
331
+ return ui.system(t('m.notFound', { target, list: agentList(ctl) }), 'error');
332
+ const agents = target === 'all' ? [...ctl.board.agents.values()] : targetInfo ? [targetInfo] : [];
333
+ const ids = new Set(agents.map((a) => a.id));
334
+ const names = new Set(agents.map((a) => a.name));
335
+ const changes = target === 'all' ? ctl.board.changes : ctl.board.changes.filter((c) => ids.has(c.agentId));
336
+ const warnings = target === 'all'
337
+ ? ctl.board.workMapWarnings
338
+ : ctl.board.workMapWarnings.filter((w) => w.agentNames.some((name) => names.has(name)) || w.paths.some((p) => changes.some((c) => c.path === p)));
339
+ const reviewTarget = target === 'all' ? 'all agents' : `${targetInfo?.name ?? target}`;
340
+ spawnFrom(`Reviewer: ${buildReviewPrompt(reviewTarget, customPrompt, agents, changes, warnings)}`, ctl, ui, images, undefined, 'ask');
341
+ return;
342
+ }
290
343
  case '/issue': {
291
344
  // Import a task from GitHub Issues (requires the gh CLI, authenticated).
292
345
  const n = Number.parseInt(arg, 10);
@@ -70,6 +70,7 @@ export class Controller extends EventEmitter {
70
70
  sessionName;
71
71
  /** Conversation JSONL path per agent id — what makes /restore possible. */
72
72
  conversationFiles = new Map();
73
+ noteNudgeLast = new Map();
73
74
  /** The session restored at startup (source of /restore conversations). */
74
75
  loadedSession = null;
75
76
  sessionOnlyProvider = null;
@@ -87,19 +88,7 @@ export class Controller extends EventEmitter {
87
88
  };
88
89
  this.board.on('update', () => this.emit('update'));
89
90
  this.board.on('agent-event', (ev) => this.onAgentEvent(ev));
90
- // Only the USER interrupts an agent's in-flight model call (steering).
91
- // Agent→agent notes never cut each other off: they are injected at the
92
- // recipient's NEXT step, together with the live snapshot + diffs — agents
93
- // adapt at action boundaries, they don't interrupt one another.
94
- this.board.on('note', (note) => {
95
- if (note.to === 'all' || note.to === 'user')
96
- return;
97
- if (note.from !== 'user')
98
- return;
99
- const target = this.findAgent(note.to);
100
- if (target)
101
- target.nudge();
102
- });
91
+ this.board.on('note', (note) => this.nudgeFromNote(note));
103
92
  // Autosave: the session (+ conversations, written live by agents) survives a crash.
104
93
  const autosave = setInterval(() => this.saveSession(), 30_000);
105
94
  autosave.unref();
@@ -182,6 +171,27 @@ export class Controller extends EventEmitter {
182
171
  setTimeout(() => process.stdout.write(''), i * 250);
183
172
  }
184
173
  }
174
+ nudgeFromNote(note) {
175
+ if (note.to === 'user' || note.from === 'system')
176
+ return;
177
+ const recipients = note.to === 'all'
178
+ ? [...this.board.agents.values()].filter((a) => a.name.toLowerCase() !== note.from.toLowerCase())
179
+ : [this.board.getAgentByName(note.to)].filter((a) => Boolean(a));
180
+ const active = recipients.filter((a) => !['done', 'error', 'stopped'].includes(a.state));
181
+ for (const info of active) {
182
+ const agent = this.agents.get(info.id);
183
+ if (!agent)
184
+ continue;
185
+ const now = Date.now();
186
+ const key = `${info.id}:${note.from.toLowerCase()}`;
187
+ const rateLimitMs = note.from === 'user' ? 0 : 1500;
188
+ if (rateLimitMs > 0 && now - (this.noteNudgeLast.get(key) ?? 0) < rateLimitMs)
189
+ continue;
190
+ if (agent.nudge(`reading team update from ${note.from}`)) {
191
+ this.noteNudgeLast.set(key, now);
192
+ }
193
+ }
194
+ }
185
195
  // ---------- approvals ----------
186
196
  requestApproval = (agentId, command) => {
187
197
  const base = command.trim().split(/\s+/)[0];
@@ -637,11 +647,14 @@ export class Controller extends EventEmitter {
637
647
  tokensIn: a.tokensIn,
638
648
  tokensOut: a.tokensOut,
639
649
  cost: a.cost,
650
+ endedAt: a.endedAt,
640
651
  providerName,
641
652
  model: a.model,
642
653
  specialist: a.specialist,
643
654
  claims: a.claims,
644
655
  ctxPct: a.ctxPct,
656
+ progressSteps: a.progressSteps,
657
+ perf: a.perf,
645
658
  conversation: this.conversationFiles.get(a.id),
646
659
  })),
647
660
  notes: this.board.notes.slice(-200),
@@ -18,10 +18,12 @@ export class Blackboard extends EventEmitter {
18
18
  changes = [];
19
19
  logs = [];
20
20
  workMapWarnings = [];
21
+ fileRevisions = new Map();
21
22
  noteSeq = 0;
22
23
  changeSeq = 0;
23
24
  logSeq = 0;
24
25
  persistTimer = null;
26
+ recentNoteKeys = new Map();
25
27
  constructor(projectRoot) {
26
28
  super();
27
29
  this.projectRoot = projectRoot;
@@ -56,8 +58,14 @@ export class Blackboard extends EventEmitter {
56
58
  if (action !== undefined)
57
59
  a.currentAction = action;
58
60
  // A finished agent no longer holds any declared work area.
59
- if (state === 'done' || state === 'stopped' || state === 'error')
61
+ if (state === 'done' || state === 'stopped' || state === 'error') {
60
62
  a.claims = undefined;
63
+ if (!a.endedAt)
64
+ a.endedAt = Date.now();
65
+ }
66
+ else {
67
+ a.endedAt = undefined;
68
+ }
61
69
  if (prev !== state)
62
70
  this.emit('agent-event', { type: 'state', id, state, prev });
63
71
  this.touch();
@@ -79,6 +87,7 @@ export class Blackboard extends EventEmitter {
79
87
  agentId,
80
88
  agentName: agent?.name ?? agentId,
81
89
  op,
90
+ revision: this.fileRevision(relPath),
82
91
  ts: Date.now(),
83
92
  });
84
93
  this.touch();
@@ -91,10 +100,22 @@ export class Blackboard extends EventEmitter {
91
100
  if (target)
92
101
  to = target.name;
93
102
  }
94
- const note = { id: ++this.noteSeq, from, to, content, ts: Date.now() };
103
+ const now = Date.now();
104
+ const key = `${from.toLowerCase()}\u0000${to.toLowerCase()}\u0000${content.trim()}`;
105
+ const recent = this.recentNoteKeys.get(key);
106
+ if (recent && now - recent.ts < 10_000)
107
+ return recent;
108
+ const note = { id: ++this.noteSeq, from, to, content, ts: now };
95
109
  this.notes.push(note);
96
110
  if (this.notes.length > 400)
97
111
  this.notes.splice(0, this.notes.length - 400);
112
+ this.recentNoteKeys.set(key, note);
113
+ if (this.recentNoteKeys.size > 100) {
114
+ for (const [k, n] of this.recentNoteKeys) {
115
+ if (now - n.ts > 30_000 || this.recentNoteKeys.size > 100)
116
+ this.recentNoteKeys.delete(k);
117
+ }
118
+ }
98
119
  this.log('', 'note', `✉ ${from} → ${to}: ${content}`);
99
120
  // Lets the controller nudge the recipient so it reads the note NOW
100
121
  // instead of at its next natural turn.
@@ -112,6 +133,9 @@ export class Blackboard extends EventEmitter {
112
133
  // ---------- file changes (real-time diff feed) ----------
113
134
  addChange(agentId, relPath, before, after) {
114
135
  const agent = this.agents.get(agentId);
136
+ const beforeRevision = this.fileRevision(relPath);
137
+ const afterRevision = beforeRevision + 1;
138
+ this.fileRevisions.set(relPath, afterRevision);
115
139
  const change = {
116
140
  id: ++this.changeSeq,
117
141
  agentId,
@@ -119,6 +143,8 @@ export class Blackboard extends EventEmitter {
119
143
  path: relPath,
120
144
  before,
121
145
  after,
146
+ beforeRevision,
147
+ afterRevision,
122
148
  ts: Date.now(),
123
149
  };
124
150
  this.changes.push(change);
@@ -160,6 +186,14 @@ export class Blackboard extends EventEmitter {
160
186
  lastChangeId() {
161
187
  return this.changes.length > 0 ? this.changes[this.changes.length - 1].id : 0;
162
188
  }
189
+ fileRevision(relPath) {
190
+ return this.fileRevisions.get(relPath) ?? 0;
191
+ }
192
+ resolveConflict(relPath) {
193
+ this.conflictCounts.delete(relPath);
194
+ this.workMapWarnings = this.workMapWarnings.filter((w) => w.id !== `conflict:${relPath}`);
195
+ this.touch();
196
+ }
163
197
  static normClaim(p) {
164
198
  return p.trim().replace(/\\/g, '/').replace(/^\.\/+/, '').replace(/\/+$/, '');
165
199
  }
@@ -295,6 +329,14 @@ export class Blackboard extends EventEmitter {
295
329
  this.workMapWarnings = [...(data.workMapWarnings ?? [])].sort((a, b) => a.ts - b.ts);
296
330
  this.noteSeq = this.notes.reduce((max, n) => Math.max(max, n.id), 0);
297
331
  this.changeSeq = this.changes.reduce((max, c) => Math.max(max, c.id), 0);
332
+ this.fileRevisions = new Map();
333
+ for (const c of this.changes) {
334
+ this.fileRevisions.set(c.path, Math.max(this.fileRevision(c.path), c.afterRevision ?? c.id));
335
+ }
336
+ for (const a of this.fileActivity.values()) {
337
+ if (a.revision !== undefined)
338
+ this.fileRevisions.set(a.path, Math.max(this.fileRevision(a.path), a.revision));
339
+ }
298
340
  this.touch();
299
341
  }
300
342
  }