@parallel-cli/parallel 0.4.5 → 0.4.7
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/CHANGELOG.md +53 -0
- package/README.md +79 -14
- package/dist/agents/agent.js +98 -14
- package/dist/agents/tools.js +218 -20
- package/dist/commands.js +83 -0
- package/dist/controller.js +24 -14
- package/dist/coordination/blackboard.js +37 -1
- package/dist/i18n.js +84 -12
- package/dist/index.js +10 -0
- package/dist/ui/AgentPanel.js +89 -31
- package/dist/ui/App.js +27 -25
- package/dist/ui/AttachApp.js +100 -15
- package/dist/ui/CommandInput.js +97 -41
- package/dist/ui/Md.js +4 -3
- package/dist/ui/SettingsPanel.js +2 -1
- package/dist/ui/Timeline.js +5 -0
- package/dist/ui/Wizard.js +11 -5
- package/dist/ui/events.js +20 -17
- package/dist/ui/theme.js +3 -3
- package/dist/ui/tokens.js +13 -8
- package/dist/ui/views.js +64 -15
- package/dist/update.js +125 -0
- package/dist/version.js +2 -0
- package/package.json +2 -2
package/dist/agents/tools.js
CHANGED
|
@@ -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.
|
|
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.
|
|
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
|
-
|
|
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 =
|
|
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' },
|
|
@@ -60,12 +61,44 @@ export const COMMANDS = [
|
|
|
60
61
|
export function visibleCommands() {
|
|
61
62
|
return COMMANDS.filter((c) => !c.hidden);
|
|
62
63
|
}
|
|
64
|
+
const COMMAND_GROUP_ORDER = ['modes', 'control', 'views', 'settings', 'git', 'other'];
|
|
65
|
+
const COMMAND_PALETTE_PRIORITY = [
|
|
66
|
+
'/ask',
|
|
67
|
+
'/task',
|
|
68
|
+
'/plan',
|
|
69
|
+
'/review',
|
|
70
|
+
'/send',
|
|
71
|
+
'/focus',
|
|
72
|
+
'/attach',
|
|
73
|
+
'/agents',
|
|
74
|
+
'/board',
|
|
75
|
+
'/diff',
|
|
76
|
+
'/settings',
|
|
77
|
+
'/help',
|
|
78
|
+
'/quit',
|
|
79
|
+
];
|
|
80
|
+
function commandRank(c) {
|
|
81
|
+
const priority = COMMAND_PALETTE_PRIORITY.indexOf(c.name);
|
|
82
|
+
if (priority !== -1)
|
|
83
|
+
return priority;
|
|
84
|
+
const group = COMMAND_GROUP_ORDER.indexOf(c.group ?? 'other');
|
|
85
|
+
return COMMAND_PALETTE_PRIORITY.length + group * 100 + COMMANDS.indexOf(c);
|
|
86
|
+
}
|
|
87
|
+
export function sortCommandsForPalette(commands) {
|
|
88
|
+
return [...commands].sort((a, b) => commandRank(a) - commandRank(b) || a.name.localeCompare(b.name));
|
|
89
|
+
}
|
|
63
90
|
export function matchCommands(input, opts = {}) {
|
|
64
91
|
if (!input.startsWith('/'))
|
|
65
92
|
return [];
|
|
66
93
|
const word = input.split(/\s+/)[0].toLowerCase();
|
|
67
94
|
return COMMANDS.filter((c) => opts.includeHidden || !c.hidden).filter((c) => c.name.startsWith(word) || c.aliases?.some((a) => a.startsWith(word)));
|
|
68
95
|
}
|
|
96
|
+
export function commandPalette(input, opts = {}) {
|
|
97
|
+
const allowed = opts.allowedNames
|
|
98
|
+
? (c) => opts.allowedNames.includes(c.name) || c.aliases?.some((a) => opts.allowedNames.includes(a))
|
|
99
|
+
: () => true;
|
|
100
|
+
return sortCommandsForPalette(matchCommands(input, opts).filter(allowed));
|
|
101
|
+
}
|
|
69
102
|
function agentList(ctl) {
|
|
70
103
|
return [...ctl.board.agents.values()].map((a) => a.name).join(', ') || t('m.none');
|
|
71
104
|
}
|
|
@@ -143,6 +176,37 @@ function soloAgent(ctl) {
|
|
|
143
176
|
const list = [...ctl.board.agents.values()];
|
|
144
177
|
return list.length === 1 ? list[0].name : null;
|
|
145
178
|
}
|
|
179
|
+
export function buildReviewPrompt(targetLabel, customPrompt, agents, changes, warnings) {
|
|
180
|
+
const files = [...new Set(changes.map((c) => c.path))].slice(0, 30);
|
|
181
|
+
const agentLines = agents.map((a) => `- ${a.name}${a.alias !== a.name ? ` (${a.alias})` : ''} [${a.state}] ${a.task}`).join('\n') || '- no target agent';
|
|
182
|
+
const fileLines = files.map((f) => `- ${f}`).join('\n') || '- no tracked file changes yet; inspect git status/read relevant files';
|
|
183
|
+
const warningLines = warnings.map((w) => `- ${w.level.toUpperCase()}: ${w.title} (${w.paths.join(', ') || 'no path'}) ${w.detail}`).join('\n') || '- none';
|
|
184
|
+
return `Review target: ${targetLabel}
|
|
185
|
+
${customPrompt ? `Extra reviewer instruction: ${customPrompt}\n` : ''}
|
|
186
|
+
You are a lightweight reviewer running in ask mode. Do not edit files and do not gate the whole session.
|
|
187
|
+
|
|
188
|
+
Target agents:
|
|
189
|
+
${agentLines}
|
|
190
|
+
|
|
191
|
+
Tracked files to inspect:
|
|
192
|
+
${fileLines}
|
|
193
|
+
|
|
194
|
+
Coordination warnings:
|
|
195
|
+
${warningLines}
|
|
196
|
+
|
|
197
|
+
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.
|
|
198
|
+
|
|
199
|
+
Return exactly this structure:
|
|
200
|
+
Verdict: APPROVE | REVISE | BLOCK
|
|
201
|
+
Risks:
|
|
202
|
+
- concrete risk or "none"
|
|
203
|
+
Tests to run:
|
|
204
|
+
- exact command or manual check
|
|
205
|
+
Files to inspect:
|
|
206
|
+
- path and why
|
|
207
|
+
Notes:
|
|
208
|
+
- short reviewer guidance for the user and agents`;
|
|
209
|
+
}
|
|
146
210
|
function spawnFrom(arg, ctl, ui, images, specialist, mode = 'task') {
|
|
147
211
|
const p = ctl.sessionProvider();
|
|
148
212
|
if (!p)
|
|
@@ -256,6 +320,25 @@ export function executeInput(raw, ctl, ui, images) {
|
|
|
256
320
|
spawnFrom(arg, ctl, ui, images, undefined, 'plan');
|
|
257
321
|
return;
|
|
258
322
|
}
|
|
323
|
+
case '/review': {
|
|
324
|
+
const [maybeTarget, ...promptParts] = rest;
|
|
325
|
+
const targetInfo = maybeTarget && maybeTarget.toLowerCase() !== 'all' ? ctl.board.getAgentByName(maybeTarget) : undefined;
|
|
326
|
+
const hasExplicitTarget = Boolean(maybeTarget && (maybeTarget.toLowerCase() === 'all' || targetInfo));
|
|
327
|
+
const target = hasExplicitTarget && maybeTarget ? maybeTarget : 'all';
|
|
328
|
+
const customPrompt = hasExplicitTarget ? promptParts.join(' ').trim() : arg;
|
|
329
|
+
if (target !== 'all' && !targetInfo)
|
|
330
|
+
return ui.system(t('m.notFound', { target, list: agentList(ctl) }), 'error');
|
|
331
|
+
const agents = target === 'all' ? [...ctl.board.agents.values()] : targetInfo ? [targetInfo] : [];
|
|
332
|
+
const ids = new Set(agents.map((a) => a.id));
|
|
333
|
+
const names = new Set(agents.map((a) => a.name));
|
|
334
|
+
const changes = target === 'all' ? ctl.board.changes : ctl.board.changes.filter((c) => ids.has(c.agentId));
|
|
335
|
+
const warnings = target === 'all'
|
|
336
|
+
? ctl.board.workMapWarnings
|
|
337
|
+
: ctl.board.workMapWarnings.filter((w) => w.agentNames.some((name) => names.has(name)) || w.paths.some((p) => changes.some((c) => c.path === p)));
|
|
338
|
+
const reviewTarget = target === 'all' ? 'all agents' : `${targetInfo?.name ?? target}`;
|
|
339
|
+
spawnFrom(`Reviewer: ${buildReviewPrompt(reviewTarget, customPrompt, agents, changes, warnings)}`, ctl, ui, images, undefined, 'ask');
|
|
340
|
+
return;
|
|
341
|
+
}
|
|
259
342
|
case '/issue': {
|
|
260
343
|
// Import a task from GitHub Issues (requires the gh CLI, authenticated).
|
|
261
344
|
const n = Number.parseInt(arg, 10);
|
package/dist/controller.js
CHANGED
|
@@ -9,7 +9,7 @@ import { saveConfig, getProvider, upsertProvider } from './config.js';
|
|
|
9
9
|
import { priceFor, fmtCost } from './pricing.js';
|
|
10
10
|
import { loadSkills, loadSpecialists } from './skills.js';
|
|
11
11
|
import { t } from './i18n.js';
|
|
12
|
-
const AGENT_COLORS = ['
|
|
12
|
+
const AGENT_COLORS = ['#f3e7c7', 'magenta', 'yellow', 'green', 'whiteBright', 'redBright', '#c8bfa6', 'magentaBright'];
|
|
13
13
|
export function normalizeShellApprovalMode(mode) {
|
|
14
14
|
if (mode === 'ask' || mode === 'auto-safe' || mode === 'yolo')
|
|
15
15
|
return mode;
|
|
@@ -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
|
-
|
|
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];
|
|
@@ -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;
|
|
@@ -79,6 +81,7 @@ export class Blackboard extends EventEmitter {
|
|
|
79
81
|
agentId,
|
|
80
82
|
agentName: agent?.name ?? agentId,
|
|
81
83
|
op,
|
|
84
|
+
revision: this.fileRevision(relPath),
|
|
82
85
|
ts: Date.now(),
|
|
83
86
|
});
|
|
84
87
|
this.touch();
|
|
@@ -91,10 +94,22 @@ export class Blackboard extends EventEmitter {
|
|
|
91
94
|
if (target)
|
|
92
95
|
to = target.name;
|
|
93
96
|
}
|
|
94
|
-
const
|
|
97
|
+
const now = Date.now();
|
|
98
|
+
const key = `${from.toLowerCase()}\u0000${to.toLowerCase()}\u0000${content.trim()}`;
|
|
99
|
+
const recent = this.recentNoteKeys.get(key);
|
|
100
|
+
if (recent && now - recent.ts < 10_000)
|
|
101
|
+
return recent;
|
|
102
|
+
const note = { id: ++this.noteSeq, from, to, content, ts: now };
|
|
95
103
|
this.notes.push(note);
|
|
96
104
|
if (this.notes.length > 400)
|
|
97
105
|
this.notes.splice(0, this.notes.length - 400);
|
|
106
|
+
this.recentNoteKeys.set(key, note);
|
|
107
|
+
if (this.recentNoteKeys.size > 100) {
|
|
108
|
+
for (const [k, n] of this.recentNoteKeys) {
|
|
109
|
+
if (now - n.ts > 30_000 || this.recentNoteKeys.size > 100)
|
|
110
|
+
this.recentNoteKeys.delete(k);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
98
113
|
this.log('', 'note', `✉ ${from} → ${to}: ${content}`);
|
|
99
114
|
// Lets the controller nudge the recipient so it reads the note NOW
|
|
100
115
|
// instead of at its next natural turn.
|
|
@@ -112,6 +127,9 @@ export class Blackboard extends EventEmitter {
|
|
|
112
127
|
// ---------- file changes (real-time diff feed) ----------
|
|
113
128
|
addChange(agentId, relPath, before, after) {
|
|
114
129
|
const agent = this.agents.get(agentId);
|
|
130
|
+
const beforeRevision = this.fileRevision(relPath);
|
|
131
|
+
const afterRevision = beforeRevision + 1;
|
|
132
|
+
this.fileRevisions.set(relPath, afterRevision);
|
|
115
133
|
const change = {
|
|
116
134
|
id: ++this.changeSeq,
|
|
117
135
|
agentId,
|
|
@@ -119,6 +137,8 @@ export class Blackboard extends EventEmitter {
|
|
|
119
137
|
path: relPath,
|
|
120
138
|
before,
|
|
121
139
|
after,
|
|
140
|
+
beforeRevision,
|
|
141
|
+
afterRevision,
|
|
122
142
|
ts: Date.now(),
|
|
123
143
|
};
|
|
124
144
|
this.changes.push(change);
|
|
@@ -160,6 +180,14 @@ export class Blackboard extends EventEmitter {
|
|
|
160
180
|
lastChangeId() {
|
|
161
181
|
return this.changes.length > 0 ? this.changes[this.changes.length - 1].id : 0;
|
|
162
182
|
}
|
|
183
|
+
fileRevision(relPath) {
|
|
184
|
+
return this.fileRevisions.get(relPath) ?? 0;
|
|
185
|
+
}
|
|
186
|
+
resolveConflict(relPath) {
|
|
187
|
+
this.conflictCounts.delete(relPath);
|
|
188
|
+
this.workMapWarnings = this.workMapWarnings.filter((w) => w.id !== `conflict:${relPath}`);
|
|
189
|
+
this.touch();
|
|
190
|
+
}
|
|
163
191
|
static normClaim(p) {
|
|
164
192
|
return p.trim().replace(/\\/g, '/').replace(/^\.\/+/, '').replace(/\/+$/, '');
|
|
165
193
|
}
|
|
@@ -295,6 +323,14 @@ export class Blackboard extends EventEmitter {
|
|
|
295
323
|
this.workMapWarnings = [...(data.workMapWarnings ?? [])].sort((a, b) => a.ts - b.ts);
|
|
296
324
|
this.noteSeq = this.notes.reduce((max, n) => Math.max(max, n.id), 0);
|
|
297
325
|
this.changeSeq = this.changes.reduce((max, c) => Math.max(max, c.id), 0);
|
|
326
|
+
this.fileRevisions = new Map();
|
|
327
|
+
for (const c of this.changes) {
|
|
328
|
+
this.fileRevisions.set(c.path, Math.max(this.fileRevision(c.path), c.afterRevision ?? c.id));
|
|
329
|
+
}
|
|
330
|
+
for (const a of this.fileActivity.values()) {
|
|
331
|
+
if (a.revision !== undefined)
|
|
332
|
+
this.fileRevisions.set(a.path, Math.max(this.fileRevision(a.path), a.revision));
|
|
333
|
+
}
|
|
298
334
|
this.touch();
|
|
299
335
|
}
|
|
300
336
|
}
|